Calling: Improve the Picture-in-Picture popout

This commit is contained in:
Scott Nonnenberg 2025-04-08 10:11:36 +10:00 committed by GitHub
commit a623ee44c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 941 additions and 261 deletions

View file

@ -4123,6 +4123,14 @@
"messageformat": "Leave", "messageformat": "Leave",
"description": "Title for the hangup button for a group call." "description": "Title for the hangup button for a group call."
}, },
"icu:CallControls__JoinLeaveButton--hangup-1-1-tooltip": {
"messageformat": "End call",
"description": "The tooltip for the hangup button in the PIP for 1:1 calls"
},
"icu:CallControls__JoinLeaveButton--hangup-group-tooltip": {
"messageformat": "Leave call",
"description": "The tooltip for the hangup button in the PIP for group calls"
},
"icu:CallControls__MutedToast--muted": { "icu:CallControls__MutedToast--muted": {
"messageformat": "Mic off", "messageformat": "Mic off",
"description": "Shown in a call when the user mutes their audio input using the Mute toggle button." "description": "Shown in a call when the user mutes their audio input using the Mute toggle button."

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

View file

@ -1 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.98 3.75a.729.729 0 0 0-.73-.73h-4.583a.73.73 0 1 0 0 1.46h3.032l-1.256 1.046-2.709 2.708a.73.73 0 0 0 1.031 1.032l2.709-2.709 1.047-1.256v3.032a.73.73 0 1 0 1.458 0V3.75ZM3.234 16.766a.73.73 0 0 1-.213-.516v-4.583a.73.73 0 0 1 1.458 0v3.032l1.047-1.256 2.708-2.709a.73.73 0 0 1 1.032 1.032l-2.709 2.708-1.256 1.047h3.032a.73.73 0 1 1 0 1.458H3.75a.729.729 0 0 1-.516-.213Z" fill="#000"/></svg> <svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16.98 16.25a.729.729 0 0 1-.73.73h-4.583a.73.73 0 0 1 0-1.46h3.032l-1.256-1.046-2.709-2.708a.73.73 0 0 1 1.031-1.032l2.709 2.709 1.047 1.256v-3.032a.73.73 0 1 1 1.458 0v4.583ZM3.234 3.234a.73.73 0 0 0-.213.516v4.583a.73.73 0 1 0 1.458 0V5.301l1.047 1.256 2.708 2.709a.73.73 0 1 0 1.032-1.032L6.557 5.526 5.301 4.48h3.032a.73.73 0 1 0 0-1.458H3.75a.73.73 0 0 0-.516.213Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 481 B

After

Width:  |  Height:  |  Size: 475 B

Before After
Before After

View file

@ -1 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.27 11.458a.73.73 0 0 0-.728-.729H3.958a.73.73 0 1 0 0 1.459h3.033l-1.257 1.046-2.708 2.709a.73.73 0 1 0 1.031 1.03l2.709-2.707 1.046-1.257v3.033a.73.73 0 0 0 1.459 0v-4.584Zm1.673-2.401a.73.73 0 0 1-.214-.515V3.958a.73.73 0 1 1 1.459 0v3.033l1.046-1.257 2.709-2.708a.73.73 0 1 1 1.03 1.031l-2.707 2.709-1.257 1.046h3.033a.73.73 0 0 1 0 1.459h-4.584a.73.73 0 0 1-.515-.214Z" fill="#000"/></svg> <svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M9.27 8.542a.73.73 0 0 1-.728.729H3.958a.73.73 0 0 1 0-1.459h3.033L5.734 6.766 3.026 4.057a.73.73 0 1 1 1.031-1.03l2.709 2.707 1.046 1.257V3.958a.73.73 0 0 1 1.459 0v4.584Zm1.673 2.401a.73.73 0 0 0-.214.515v4.584a.73.73 0 1 0 1.459 0v-3.033l1.046 1.257 2.709 2.708a.73.73 0 1 0 1.03-1.031l-2.707-2.709-1.257-1.046h3.033a.73.73 0 0 0 0-1.459h-4.584a.73.73 0 0 0-.515.214Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 480 B

After

Width:  |  Height:  |  Size: 475 B

Before After
Before After

View file

@ -43,7 +43,6 @@
"prepare-adhoc-build": "node scripts/prepare_adhoc_build.js", "prepare-adhoc-build": "node scripts/prepare_adhoc_build.js",
"prepare-adhoc-version": "node scripts/prepare_tagged_version.js adhoc", "prepare-adhoc-version": "node scripts/prepare_tagged_version.js adhoc",
"prepare-staging-build": "node scripts/prepare_staging_build.js", "prepare-staging-build": "node scripts/prepare_staging_build.js",
"prepare-windows-cert": "node scripts/prepare_windows_cert.js",
"test": "run-s test-node test-electron test-lint-intl test-eslint", "test": "run-s test-node test-electron test-lint-intl test-eslint",
"test-electron": "node ts/scripts/test-electron.js", "test-electron": "node ts/scripts/test-electron.js",
"test-release": "node ts/scripts/test-release.js", "test-release": "node ts/scripts/test-release.js",

View file

@ -1,48 +0,0 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
const fs = require('fs');
const _ = require('lodash');
const packageJson = require('../package.json');
// We have different windows certificates used in each of our build machines, and this
// script makes it easier to ready the app to build on a given machine.
// -------
const KEY = 'build.win.certificateSha1';
const DEFAULT_VALUE = '8C9A0B5C852EC703D83EF7BFBCEB54B796073759';
const BUILDER_A = '507769334DA990A8DDE858314B0CDFC228E7CFA1';
const BUILDER_B = 'C689B0988CA1A7DF99E4CE4433AC7EA8B82F8D41';
let targetValue = DEFAULT_VALUE;
if (process.env.WINDOWS_BUILDER === 'A') {
targetValue = BUILDER_A;
}
if (process.env.WINDOWS_BUILDER === 'B') {
targetValue = BUILDER_B;
}
// -------
function checkValue(object, objectPath, expected) {
const actual = _.get(object, objectPath);
if (actual !== expected) {
throw new Error(`${objectPath} was ${actual}; expected ${expected}`);
}
}
// ------
checkValue(packageJson, KEY, DEFAULT_VALUE);
// -------
_.set(packageJson, KEY, targetValue);
// -------
fs.writeFileSync('./package.json', JSON.stringify(packageJson, null, ' '));

View file

@ -3882,14 +3882,17 @@ button.module-image__border-overlay:focus {
} }
&__background { &__background {
align-items: center; position: absolute;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
display: flex; display: flex;
align-items: center;
flex-direction: column; flex-direction: column;
height: 100%;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
position: relative;
width: 100%;
&--blur { &--blur {
background-repeat: no-repeat; background-repeat: no-repeat;
@ -3989,9 +3992,10 @@ button.module-image__border-overlay:focus {
} }
.module-ongoing-call { .module-ongoing-call {
&__remote-video-enabled { &__remote-video-enabled {
background-color: variables.$color-gray-95; // TODO: DESKTOP-8537 remove this; we want blurred avatar not all-black letterboxing
height: 100%; height: 100%;
width: 100%; width: 100%;
position: relative;
&--reconnecting { &--reconnecting {
filter: blur(15px); filter: blur(15px);
} }
@ -4002,6 +4006,7 @@ button.module-image__border-overlay:focus {
height: 100vh; height: 100vh;
width: 100%; width: 100%;
display: flex; display: flex;
position: relative;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
@ -4104,6 +4109,7 @@ button.module-image__border-overlay:focus {
// Only apply container-type: size to grid column to prevent size collapse // Only apply container-type: size to grid column to prevent size collapse
// for implicitly sized participants (PiP) // for implicitly sized participants (PiP)
container-type: size; container-type: size;
position: relative;
@container (min-width: 180px) or (min-height: 180px) { @container (min-width: 180px) or (min-height: 180px) {
.module-ongoing-call__group-call-remote-participant__footer { .module-ongoing-call__group-call-remote-participant__footer {
@ -4625,14 +4631,17 @@ button.module-image__border-overlay:focus {
.module-calling-pip { .module-calling-pip {
backface-visibility: hidden; backface-visibility: hidden;
background-color: variables.$color-gray-95; background-color: variables.$color-gray-95;
border-radius: 4px; border-radius: 18px;
box-shadow: box-shadow:
0px 0px 8px rgba(0, 0, 0, 0.05), 0px 0px 8px rgba(0, 0, 0, 0.05),
0px 8px 20px rgba(0, 0, 0, 0.3); 0px 8px 20px rgba(0, 0, 0, 0.3);
cursor: grab; cursor: grab;
height: 158px; // This is just a starting height; the component will figure out what height it should
// be, given the aspect ratio of the provided video, pinning the width.
// These both should be kept in sync with the height/width in CallingPip.tsx
height: 286px;
width: 160px;
position: fixed; position: fixed;
width: 120px;
z-index: variables.$z-index-calling-pip; z-index: variables.$z-index-calling-pip;
& .module-ongoing-call__group-call-remote-participant { & .module-ongoing-call__group-call-remote-participant {
@ -4643,13 +4652,13 @@ button.module-image__border-overlay:focus {
&--remote { &--remote {
align-items: center; align-items: center;
background-color: variables.$color-gray-95; background-color: variables.$color-gray-95;
border-radius: 4px 4px 0 0; border-radius: 18px;
height: 100%;
width: 100%;
display: flex; display: flex;
height: 120px; // This height should be kept in sync with <CallingPipRemoteVideo>'s hard-coded height.
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: 100%;
// The avatar image can be dragged on Windows. // The avatar image can be dragged on Windows.
.module-Avatar img { .module-Avatar img {
@ -4664,15 +4673,20 @@ button.module-image__border-overlay:focus {
&--local, &--local,
&--local-presenting { &--local-presenting {
bottom: 38px;
height: 32px;
position: absolute; position: absolute;
inset-inline-end: 4px; top: 8px;
width: 32px; inset-inline-start: 8px;
height: 54px;
width: 80px;
border-radius: 12px;
overflow: hidden;
background-color: variables.$color-gray-80;
video { video {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover;
} }
} }
@ -4681,17 +4695,116 @@ button.module-image__border-overlay:focus {
} }
} }
&__actions { &__full-size-local-preview {
align-items: center; width: 100%;
background-color: variables.$color-gray-02; position: relative;
border-radius: 0 0 4px 4px;
video {
width: 100%;
transform: rotateY(180deg);
}
&--presenting {
transform: none;
}
}
&__pills {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 38px; align-items: center;
justify-content: space-around;
@include mixins.dark-theme { position: absolute;
background-color: variables.$color-gray-65; bottom: 66px;
inset-inline-start: 8px;
transition: bottom 0.3s variables.$ease-out-local-preview 0.3s;
&--no-controls {
bottom: 8px;
}
}
&__pill {
height: 28px;
border-radius: 14px;
padding: 6px;
padding-inline-start: 12px;
padding-inline-end: 12px;
background-color: variables.$color-gray-80;
color: variables.$color-gray-05;
display: flex;
flex-direction: row;
align-items: center;
@include mixins.font-body-small;
}
&__pill-icon {
height: 16px;
width: 16px;
margin-inline-end: 4px;
&__raised-hands {
@include mixins.color-svg(
'../images/icons/v3/raise_hand/raise_hand.svg',
variables.$color-gray-05
);
}
&__group-join {
@include mixins.color-svg(
'../images/icons/v3/person/person-plus-compact.svg',
variables.$color-gray-05
);
}
}
&__actions {
position: absolute;
bottom: 4px;
inset-inline-start: 4px;
inset-inline-end: 4px;
padding: 12px;
height: 56px;
opacity: 0;
transition: opacity 1s ease-in-out;
&--visible {
opacity: 1;
}
display: flex;
align-items: center;
flex-direction: row;
border-radius: 18px;
justify-content: space-around;
background-color: variables.$color-gray-78;
&__button {
flex-shrink: 0;
flex-grow: 0;
}
&__middle-button {
flex-grow: 1;
text-align: center;
}
.CallingButton__icon {
height: 32px;
width: 32px;
}
}
&__un-pip-container {
position: absolute;
top: 16px;
inset-inline-end: 16px;
opacity: 0;
transition: opacity 1s ease-in-out;
&--visible {
opacity: 1;
} }
} }

View file

@ -101,6 +101,14 @@
} }
} }
&--full-screen-call {
@include calling-button-icon(
'../images/icons/v3/pip/pip-maximize-light.svg',
variables.$color-gray-80,
variables.$color-gray-15
);
}
&--hangup { &--hangup {
@include calling-button-icon( @include calling-button-icon(
'../images/icons/v3/phone/phone-down-fill-light.svg', '../images/icons/v3/phone/phone-down-fill-light.svg',
@ -264,3 +272,9 @@
width: 16px; width: 16px;
} }
} }
.module-calling-pip {
.CallingButton__button-container {
margin: 0;
}
}

View file

@ -122,8 +122,8 @@ export type PropsType = {
sendGroupCallReaction: (payload: SendGroupCallReactionType) => void; sendGroupCallReaction: (payload: SendGroupCallReactionType) => void;
setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void; setGroupCallVideoRequest: (_: SetGroupCallVideoRequestType) => void;
setIsCallActive: (_: boolean) => void; setIsCallActive: (_: boolean) => void;
setLocalAudio: (_: SetLocalAudioType) => void; setLocalAudio: SetLocalAudioType;
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: SetLocalVideoType;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setOutgoingRing: (_: boolean) => void; setOutgoingRing: (_: boolean) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
@ -345,14 +345,19 @@ function ActiveCallManager({
getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall} getGroupCallVideoFrameSource={getGroupCallVideoFrameSourceForActiveCall}
imageDataCache={imageDataCache} imageDataCache={imageDataCache}
hangUpActiveCall={hangUpActiveCall} hangUpActiveCall={hangUpActiveCall}
hasLocalVideo={hasLocalVideo}
i18n={i18n} i18n={i18n}
me={me}
setGroupCallVideoRequest={setGroupCallVideoRequestForConversation} setGroupCallVideoRequest={setGroupCallVideoRequestForConversation}
setLocalPreviewContainer={setLocalPreviewContainer} setLocalPreviewContainer={setLocalPreviewContainer}
setRendererCanvas={setRendererCanvas} setRendererCanvas={setRendererCanvas}
switchToPresentationView={switchToPresentationView} switchToPresentationView={switchToPresentationView}
switchFromPresentationView={switchFromPresentationView} switchFromPresentationView={switchFromPresentationView}
toggleAudio={setLocalAudio}
togglePip={togglePip} togglePip={togglePip}
toggleVideo={() => {
const enabled = !activeCall.hasLocalVideo;
setLocalVideo({ enabled });
}}
/> />
); );
} }

View file

@ -122,8 +122,8 @@ export type PropsType = {
_: Array<GroupCallVideoRequest>, _: Array<GroupCallVideoRequest>,
speakerHeight: number speakerHeight: number
) => void; ) => void;
setLocalAudio: (_: SetLocalAudioType) => void; setLocalAudio: SetLocalAudioType;
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: SetLocalVideoType;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean; stickyControls: boolean;
@ -489,10 +489,7 @@ export function CallScreen({
)} )}
> >
{isSendingVideo ? ( {isSendingVideo ? (
<div <div ref={setLocalPreviewContainer} />
className="module-ongoing-call__local-preview-container"
ref={setLocalPreviewContainer}
/>
) : ( ) : (
<CallBackgroundBlur avatarUrl={me.avatarUrl}> <CallBackgroundBlur avatarUrl={me.avatarUrl}>
<div className="module-calling__spacer module-calling__camera-is-off-spacer" /> <div className="module-calling__spacer module-calling__camera-is-off-spacer" />

View file

@ -13,6 +13,9 @@ export enum CallingButtonType {
AUDIO_DISABLED = 'AUDIO_DISABLED', AUDIO_DISABLED = 'AUDIO_DISABLED',
AUDIO_OFF = 'AUDIO_OFF', AUDIO_OFF = 'AUDIO_OFF',
AUDIO_ON = 'AUDIO_ON', AUDIO_ON = 'AUDIO_ON',
FULL_SCREEN_CALL = 'FULL_SCREEN_CALL',
HANGUP_GROUP = 'HANGUP_GROUP',
HANGUP_DIRECT = 'HANGUP_DIRECT',
MAXIMIZE = 'MAXIMIZE', MAXIMIZE = 'MAXIMIZE',
MINIMIZE = 'MINIMIZE', MINIMIZE = 'MINIMIZE',
MORE_OPTIONS = 'MORE_OPTIONS', MORE_OPTIONS = 'MORE_OPTIONS',
@ -117,6 +120,19 @@ export function CallingButton({
} else if (buttonType === CallingButtonType.MINIMIZE) { } else if (buttonType === CallingButtonType.MINIMIZE) {
classNameSuffix = 'minimize'; classNameSuffix = 'minimize';
tooltipContent = i18n('icu:calling__preview--minimize'); tooltipContent = i18n('icu:calling__preview--minimize');
} else if (buttonType === CallingButtonType.FULL_SCREEN_CALL) {
classNameSuffix = 'full-screen-call';
tooltipContent = i18n('icu:calling__pip--off');
} else if (buttonType === CallingButtonType.HANGUP_DIRECT) {
classNameSuffix = 'hangup';
tooltipContent = i18n(
'icu:CallControls__JoinLeaveButton--hangup-1-1-tooltip'
);
} else if (buttonType === CallingButtonType.HANGUP_GROUP) {
classNameSuffix = 'hangup';
tooltipContent = i18n(
'icu:CallControls__JoinLeaveButton--hangup-group-tooltip'
);
} }
const handleClick = React.useCallback( const handleClick = React.useCallback(

View file

@ -72,8 +72,8 @@ export type PropsType = {
onJoinCall: () => void; onJoinCall: () => void;
outgoingRing: boolean; outgoingRing: boolean;
peekedParticipants: Array<ConversationType>; peekedParticipants: Array<ConversationType>;
setLocalAudio: (_: SetLocalAudioType) => void; setLocalAudio: SetLocalAudioType;
setLocalVideo: (_: SetLocalVideoType) => void; setLocalVideo: SetLocalVideoType;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void; setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setOutgoingRing: (_: boolean) => void; setOutgoingRing: (_: boolean) => void;
showParticipantsList: boolean; showParticipantsList: boolean;

View file

@ -2,43 +2,17 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { sample } from 'lodash';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { PropsType } from './CallingParticipantsList'; import type { PropsType } from './CallingParticipantsList';
import { CallingParticipantsList } from './CallingParticipantsList'; import { CallingParticipantsList } from './CallingParticipantsList';
import { AvatarColors } from '../types/Colors';
import type { GroupCallRemoteParticipantType } from '../types/Calling';
import { generateAci } from '../types/ServiceId'; import { generateAci } from '../types/ServiceId';
import { getDefaultConversationWithServiceId } from '../test-both/helpers/getDefaultConversation'; import { createCallParticipant } from '../test-both/helpers/createCallParticipant';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
function createParticipant(
participantProps: Partial<GroupCallRemoteParticipantType>
): GroupCallRemoteParticipantType {
return {
aci: generateAci(),
demuxId: 2,
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
hasRemoteVideo: Boolean(participantProps.hasRemoteVideo),
isHandRaised: Boolean(participantProps.isHandRaised),
mediaKeysReceived: Boolean(participantProps.mediaKeysReceived),
presenting: Boolean(participantProps.presenting),
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,
...getDefaultConversationWithServiceId({
avatarUrl: participantProps.avatarUrl,
color: sample(AvatarColors),
isBlocked: Boolean(participantProps.isBlocked),
name: participantProps.name,
profileName: participantProps.title,
title: String(participantProps.title),
}),
};
}
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n, i18n,
conversationId: 'fake-conversation-id', conversationId: 'fake-conversation-id',
@ -60,7 +34,7 @@ export function NoOne(): JSX.Element {
export function SoloCall(): JSX.Element { export function SoloCall(): JSX.Element {
const props = createProps({ const props = createProps({
participants: [ participants: [
createParticipant({ createCallParticipant({
title: 'Bardock', title: 'Bardock',
}), }),
], ],
@ -71,37 +45,37 @@ export function SoloCall(): JSX.Element {
export function ManyParticipants(): JSX.Element { export function ManyParticipants(): JSX.Element {
const props = createProps({ const props = createProps({
participants: [ participants: [
createParticipant({ createCallParticipant({
title: 'Son Goku', title: 'Son Goku',
}), }),
createParticipant({ createCallParticipant({
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
presenting: true, presenting: true,
name: 'Rage Trunks', name: 'Rage Trunks',
title: 'Rage Trunks', title: 'Rage Trunks',
}), }),
createParticipant({ createCallParticipant({
hasRemoteAudio: true, hasRemoteAudio: true,
title: 'Prince Vegeta', title: 'Prince Vegeta',
}), }),
createParticipant({ createCallParticipant({
hasRemoteAudio: true, hasRemoteAudio: true,
hasRemoteVideo: true, hasRemoteVideo: true,
name: 'Goku Black', name: 'Goku Black',
title: 'Goku Black', title: 'Goku Black',
}), }),
createParticipant({ createCallParticipant({
isHandRaised: true, isHandRaised: true,
title: 'Supreme Kai Zamasu', title: 'Supreme Kai Zamasu',
}), }),
createParticipant({ createCallParticipant({
hasRemoteAudio: false, hasRemoteAudio: false,
hasRemoteVideo: true, hasRemoteVideo: true,
isHandRaised: true, isHandRaised: true,
title: 'Chi Chi', title: 'Chi Chi',
}), }),
createParticipant({ createCallParticipant({
title: 'Someone With A Really Long Name', title: 'Someone With A Really Long Name',
}), }),
], ],
@ -113,7 +87,7 @@ export function Overflow(): JSX.Element {
const props = createProps({ const props = createProps({
participants: Array(50) participants: Array(50)
.fill(null) .fill(null)
.map(() => createParticipant({ title: 'Kirby' })), .map(() => createCallParticipant({ title: 'Kirby' })),
}); });
return <CallingParticipantsList {...props} />; return <CallingParticipantsList {...props} />;
} }

View file

@ -20,9 +20,18 @@ import { CallMode } from '../types/CallDisposition';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource'; import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { MINUTE } from '../util/durations'; import { MINUTE } from '../util/durations';
import type { SetRendererCanvasType } from '../state/ducks/calling';
import { createCallParticipant } from '../test-both/helpers/createCallParticipant';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
const videoScreenshot = new Image(300, 400);
videoScreenshot.src = '../../fixtures/cat-screenshot-3x4.png';
const localPreviewVideo = document.createElement('video');
localPreviewVideo.autoplay = true;
localPreviewVideo.loop = true;
localPreviewVideo.src = '../../fixtures/pixabay-Soap-Bubble-7141.mp4';
const conversation: ConversationType = getDefaultConversation({ const conversation: ConversationType = getDefaultConversation({
id: '3051234567', id: '3051234567',
avatarUrl: undefined, avatarUrl: undefined,
@ -43,7 +52,7 @@ type Overrides = {
const getCommonActiveCallData = (overrides: Overrides) => ({ const getCommonActiveCallData = (overrides: Overrides) => ({
conversation, conversation,
hasLocalAudio: overrides.hasLocalAudio ?? true, hasLocalAudio: overrides.hasLocalAudio ?? true,
hasLocalVideo: overrides.hasLocalVideo ?? false, hasLocalVideo: overrides.hasLocalVideo ?? true,
localAudioLevel: overrides.localAudioLevel ?? 0, localAudioLevel: overrides.localAudioLevel ?? 0,
viewMode: overrides.viewMode ?? CallViewMode.Paginated, viewMode: overrides.viewMode ?? CallViewMode.Paginated,
joinedAt: Date.now() - MINUTE, joinedAt: Date.now() - MINUTE,
@ -71,21 +80,27 @@ const getDefaultCall = (overrides: Overrides): ActiveDirectCallType => {
export default { export default {
title: 'Components/CallingPip', title: 'Components/CallingPip',
argTypes: {
hasLocalVideo: { control: { type: 'boolean' } },
},
args: { args: {
activeCall: getDefaultCall({}), activeCall: getDefaultCall({}),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource, getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
hangUpActiveCall: action('hang-up-active-call'), hangUpActiveCall: action('hang-up-active-call'),
hasLocalVideo: false,
i18n, i18n,
me: getDefaultConversation({
name: 'Lonely InGroup',
title: 'Lonely InGroup',
}),
setGroupCallVideoRequest: action('set-group-call-video-request'), setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalPreviewContainer: action('set-local-preview-container'), setLocalPreviewContainer: (container: HTMLDivElement | null) => {
setRendererCanvas: action('set-renderer-canvas'), container?.appendChild(localPreviewVideo);
},
setRendererCanvas: ({ element }: SetRendererCanvasType) => {
element?.current?.getContext('2d')?.drawImage(videoScreenshot, 0, 0);
},
switchFromPresentationView: action('switch-to-presentation-view'), switchFromPresentationView: action('switch-to-presentation-view'),
switchToPresentationView: action('switch-to-presentation-view'), switchToPresentationView: action('switch-to-presentation-view'),
toggleAudio: action('toggle-audio'),
togglePip: action('toggle-pip'), togglePip: action('toggle-pip'),
toggleVideo: action('toggle-video'),
}, },
} satisfies Meta<PropsType>; } satisfies Meta<PropsType>;
@ -93,6 +108,60 @@ export function Default(args: PropsType): JSX.Element {
return <CallingPip {...args} />; return <CallingPip {...args} />;
} }
// Note: should NOT show speaking indicators
export function DefaultBothSpeaking(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getDefaultCall({}),
remoteAudioLevel: 0.75,
localAudioLevel: 0.75,
}}
/>
);
}
// Note: should NOT show mute indicator for remote party
export function RemoteMuted(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getDefaultCall({}),
hasRemoteAudio: false,
}}
/>
);
}
// Note: should NOT show show mute indicator in self preview
export function NoLocalAudio(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getDefaultCall({
hasLocalAudio: false,
}),
}}
/>
);
}
export function NoLocalVideo(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getDefaultCall({
hasLocalVideo: false,
}),
}}
/>
);
}
export function ContactWithAvatarAndNoVideo(args: PropsType): JSX.Element { export function ContactWithAvatarAndNoVideo(args: PropsType): JSX.Element {
return ( return (
<CallingPip <CallingPip
@ -126,7 +195,7 @@ export function ContactNoColor(args: PropsType): JSX.Element {
); );
} }
export function GroupCall(args: PropsType): JSX.Element { export function LonelyInGroupCall(args: PropsType): JSX.Element {
return ( return (
<CallingPip <CallingPip
{...args} {...args}
@ -151,3 +220,229 @@ export function GroupCall(args: PropsType): JSX.Element {
/> />
); );
} }
export function LonelyInGroupCallVideoDisabled(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({
hasLocalVideo: false,
}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
export function GroupCall(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>(),
remoteParticipants: [
createCallParticipant({}),
createCallParticipant({}),
],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
export function GroupCallWithRaisedHands(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [],
raisedHands: new Set<number>([1, 2, 3]),
remoteParticipants: [
createCallParticipant({}),
createCallParticipant({}),
],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
export function GroupCallWithPendingParticipants(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [
getDefaultConversation(),
getDefaultConversation(),
],
raisedHands: new Set<number>(),
remoteParticipants: [
createCallParticipant({}),
createCallParticipant({}),
],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
export function GroupCallWithPendingAndRaised(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [
getDefaultConversation(),
getDefaultConversation(),
],
raisedHands: new Set<number>([1, 2, 3]),
remoteParticipants: [
createCallParticipant({}),
createCallParticipant({}),
],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
// Note: should NOT show muted indicator for remote party
export function GroupCallRemoteMuted(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [
getDefaultConversation(),
getDefaultConversation(),
],
raisedHands: new Set<number>([1, 2, 3]),
remoteParticipants: [
{
...createCallParticipant({}),
demuxId: 1,
hasRemoteAudio: false,
hasRemoteVideo: true,
mediaKeysReceived: true,
},
],
remoteAudioLevels: new Map<number, number>(),
suggestLowerHand: false,
}}
/>
);
}
// Note: should NOT show speaking indicator
export function GroupCallRemoteSpeaking(args: PropsType): JSX.Element {
return (
<CallingPip
{...args}
activeCall={{
...getCommonActiveCallData({}),
callMode: CallMode.Group as CallMode.Group,
connectionState: GroupCallConnectionState.Connected,
conversationsByDemuxId: new Map<number, ConversationType>(),
groupMembers: times(3, () => getDefaultConversation()),
isConversationTooBigToRing: false,
joinState: GroupCallJoinState.Joined,
localDemuxId: 1,
maxDevices: 5,
deviceCount: 0,
peekedParticipants: [],
pendingParticipants: [
getDefaultConversation(),
getDefaultConversation(),
],
raisedHands: new Set<number>([1, 2, 3]),
remoteParticipants: [
{
...createCallParticipant({}),
demuxId: 1,
hasRemoteAudio: true,
hasRemoteVideo: true,
mediaKeysReceived: true,
},
],
remoteAudioLevels: new Map<number, number>([[1, 0.75]]),
suggestLowerHand: false,
}}
/>
);
}

View file

@ -2,15 +2,27 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { minBy, debounce, noop } from 'lodash'; import { minBy, debounce, noop } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { VideoFrameSource } from '@signalapp/ringrtc';
import { missingCaseError } from '../util/missingCaseError';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import { CallMode } from '../types/CallDisposition';
import { TooltipPlacement } from './Tooltip';
import { CallingButton, CallingButtonType } from './CallingButton';
import { CallingPipRemoteVideo } from './CallingPipRemoteVideo'; import { CallingPipRemoteVideo } from './CallingPipRemoteVideo';
import { CallBackgroundBlur } from './CallBackgroundBlur';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling'; import type { ActiveCallType, GroupCallVideoRequest } from '../types/Calling';
import type { SetRendererCanvasType } from '../state/ducks/calling'; import type { SetRendererCanvasType } from '../state/ducks/calling';
import { missingCaseError } from '../util/missingCaseError';
import { useActivateSpeakerViewOnPresenting } from '../hooks/useActivateSpeakerViewOnPresenting';
import type { CallingImageDataCache } from './CallManager'; import type { CallingImageDataCache } from './CallManager';
import type { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize } from './Avatar';
import { AvatarColors } from '../types/Colors';
enum PositionMode { enum PositionMode {
BeingDragged, BeingDragged,
@ -50,9 +62,21 @@ export type PropsType = {
activeCall: ActiveCallType; activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource; getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUpActiveCall: (reason: string) => void; hangUpActiveCall: (reason: string) => void;
hasLocalVideo: boolean;
i18n: LocalizerType; i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>; imageDataCache: React.RefObject<CallingImageDataCache>;
me: Readonly<
Pick<
ConversationType,
| 'avatarUrl'
| 'avatarPlaceholderGradient'
| 'color'
| 'type'
| 'phoneNumber'
| 'profileName'
| 'title'
| 'sharedGroupNames'
>
>;
setGroupCallVideoRequest: ( setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>, _: Array<GroupCallVideoRequest>,
speakerHeight: number speakerHeight: number
@ -61,32 +85,42 @@ export type PropsType = {
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
switchToPresentationView: () => void; switchToPresentationView: () => void;
switchFromPresentationView: () => void; switchFromPresentationView: () => void;
toggleAudio: () => void;
togglePip: () => void; togglePip: () => void;
toggleVideo: () => void;
}; };
const PIP_HEIGHT = 156; const PIP_STARTING_HEIGHT = 286;
const PIP_WIDTH = 120; const PIP_WIDTH = 160;
const PIP_TOP_MARGIN = 56; const PIP_TOP_MARGIN = 78;
const PIP_PADDING = 8; const PIP_PADDING = 8;
// Receiving portrait video will cause the PIP to update to match that video size, but
// we need limits
export const PIP_MINIMUM_HEIGHT = 180;
export const PIP_MAXIMUM_HEIGHT = 360;
export function CallingPip({ export function CallingPip({
activeCall, activeCall,
getGroupCallVideoFrameSource, getGroupCallVideoFrameSource,
hangUpActiveCall, hangUpActiveCall,
hasLocalVideo,
imageDataCache, imageDataCache,
i18n, i18n,
me,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setLocalPreviewContainer, setLocalPreviewContainer,
setRendererCanvas, setRendererCanvas,
switchToPresentationView, switchToPresentationView,
switchFromPresentationView, switchFromPresentationView,
toggleAudio,
togglePip, togglePip,
toggleVideo,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const isRTL = i18n.getLocaleDirection() === 'rtl'; const isRTL = i18n.getLocaleDirection() === 'rtl';
const videoContainerRef = React.useRef<null | HTMLDivElement>(null); const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
const [height, setHeight] = React.useState(PIP_STARTING_HEIGHT);
const [windowWidth, setWindowWidth] = React.useState(window.innerWidth); const [windowWidth, setWindowWidth] = React.useState(window.innerWidth);
const [windowHeight, setWindowHeight] = React.useState(window.innerHeight); const [windowHeight, setWindowHeight] = React.useState(window.innerHeight);
const [positionState, setPositionState] = React.useState<PositionState>({ const [positionState, setPositionState] = React.useState<PositionState>({
@ -112,6 +146,8 @@ export function CallingPip({
mouseX: ev.clientX, mouseX: ev.clientX,
mouseY: ev.clientY, mouseY: ev.clientY,
})); }));
ev.preventDefault();
ev.stopPropagation();
} }
}, },
[positionState] [positionState]
@ -150,7 +186,7 @@ export function CallingPip({
}, },
{ {
mode: PositionMode.SnapToBottom, mode: PositionMode.SnapToBottom,
distanceToEdge: innerHeight - (offsetY + PIP_HEIGHT), distanceToEdge: innerHeight - (offsetY + height),
}, },
]; ];
@ -179,7 +215,7 @@ export function CallingPip({
throw missingCaseError(snapTo.mode); throw missingCaseError(snapTo.mode);
} }
} }
}, [isRTL, positionState, setPositionState]); }, [height, isRTL, positionState, setPositionState]);
React.useEffect(() => { React.useEffect(() => {
if (positionState.mode === PositionMode.BeingDragged) { if (positionState.mode === PositionMode.BeingDragged) {
@ -213,6 +249,15 @@ export function CallingPip({
}, []); }, []);
const [translateX, translateY] = React.useMemo<[number, number]>(() => { const [translateX, translateY] = React.useMemo<[number, number]>(() => {
const topMin = PIP_TOP_MARGIN;
const bottomMax = windowHeight - PIP_PADDING - height;
const leftScrollPadding = isRTL ? 1 : 0;
const leftMin = PIP_PADDING + leftScrollPadding;
const rightScrollPadding = isRTL ? 0 : 1;
const rightMax = windowWidth - PIP_PADDING - PIP_WIDTH - rightScrollPadding;
switch (positionState.mode) { switch (positionState.mode) {
case PositionMode.BeingDragged: case PositionMode.BeingDragged:
return [ return [
@ -225,56 +270,170 @@ export function CallingPip({
]; ];
case PositionMode.SnapToLeft: case PositionMode.SnapToLeft:
return [ return [
PIP_PADDING, leftMin,
Math.min( Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
positionState.offsetY,
windowHeight - PIP_PADDING - PIP_HEIGHT
),
]; ];
case PositionMode.SnapToRight: case PositionMode.SnapToRight:
return [ return [
windowWidth - PIP_PADDING - PIP_WIDTH, rightMax,
Math.min( Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
positionState.offsetY,
windowHeight - PIP_PADDING - PIP_HEIGHT
),
]; ];
case PositionMode.SnapToTop: case PositionMode.SnapToTop:
return [ return [
Math.min( Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
positionState.offsetX, topMin,
windowWidth - PIP_PADDING - PIP_WIDTH
),
PIP_TOP_MARGIN + PIP_PADDING,
]; ];
case PositionMode.SnapToBottom: case PositionMode.SnapToBottom:
return [ return [
Math.min( Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
positionState.offsetX, bottomMax,
windowWidth - PIP_PADDING - PIP_WIDTH
),
windowHeight - PIP_PADDING - PIP_HEIGHT,
]; ];
default: default:
throw missingCaseError(positionState); throw missingCaseError(positionState);
} }
}, [isRTL, windowWidth, windowHeight, positionState]); }, [height, isRTL, windowWidth, windowHeight, positionState]);
const localizedTranslateX = isRTL ? -translateX : translateX; const localizedTranslateX = isRTL ? -translateX : translateX;
const [showControls, setShowControls] = React.useState(false);
const onMouseEnter = React.useCallback(() => {
setShowControls(true);
}, [setShowControls]);
const onMouseMove = React.useCallback(() => {
setShowControls(true);
}, [setShowControls]);
const [controlsHover, setControlsHover] = React.useState(false);
const onControlsMouseEnter = React.useCallback(() => {
setControlsHover(true);
}, [setControlsHover]);
const onControlsMouseLeave = React.useCallback(() => {
setControlsHover(false);
}, [setControlsHover]);
React.useEffect(() => {
if (!showControls) {
return;
}
if (controlsHover) {
return;
}
const timer = setTimeout(() => {
setShowControls(false);
}, 2000);
return clearTimeout.bind(null, timer);
}, [showControls, controlsHover, setShowControls]);
const localVideoClassName = activeCall.presentingSource const localVideoClassName = activeCall.presentingSource
? 'module-calling-pip__video--local-presenting' ? 'module-calling-pip__video--local-presenting'
: 'module-calling-pip__video--local'; : 'module-calling-pip__video--local';
let raisedHandsCount = 0;
let callJoinRequests = 0;
if (isGroupOrAdhocActiveCall(activeCall)) {
raisedHandsCount = activeCall.raisedHands.size;
callJoinRequests = activeCall.pendingParticipants.length;
}
let videoButtonType: CallingButtonType;
if (activeCall.presentingSource) {
videoButtonType = CallingButtonType.VIDEO_DISABLED;
} else if (activeCall.hasLocalVideo) {
videoButtonType = CallingButtonType.VIDEO_ON;
} else {
videoButtonType = CallingButtonType.VIDEO_OFF;
}
const audioButtonType = activeCall.hasLocalAudio
? CallingButtonType.AUDIO_ON
: CallingButtonType.AUDIO_OFF;
const hangupButtonType =
activeCall.callMode === CallMode.Direct
? CallingButtonType.HANGUP_DIRECT
: CallingButtonType.HANGUP_GROUP;
let remoteVideoNode: JSX.Element;
const isLonelyInCall = !activeCall.remoteParticipants.length;
const isSendingVideo =
activeCall.hasLocalVideo || activeCall.presentingSource;
if (isLonelyInCall) {
remoteVideoNode = (
<div className="module-calling-pip__video--remote">
{isSendingVideo ? (
// TODO: DESKTOP-8537 - when black bars go away, need to make some CSS changes
<>
<CallBackgroundBlur avatarUrl={me.avatarUrl} />
<div
className={classNames(
'module-calling-pip__full-size-local-preview',
activeCall.presentingSource
? 'module-calling-pip__full-size-local-preview--presenting'
: undefined
)}
ref={setLocalPreviewContainer}
/>
</>
) : (
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<div className="module-calling-pip__video--avatar">
<Avatar
avatarPlaceholderGradient={me.avatarPlaceholderGradient}
avatarUrl={me.avatarUrl}
badge={undefined}
color={me.color || AvatarColors[0]}
noteToSelf={false}
conversationType={me.type}
i18n={i18n}
phoneNumber={me.phoneNumber}
profileName={me.profileName}
title={me.title}
size={AvatarSize.FORTY_EIGHT}
sharedGroupNames={[]}
/>
</div>
</CallBackgroundBlur>
)}
</div>
);
} else {
remoteVideoNode = (
<CallingPipRemoteVideo
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
setGroupCallVideoRequest={setGroupCallVideoRequest}
height={height}
width={PIP_WIDTH}
updateHeight={(newHeight: number) => {
setHeight(newHeight);
}}
/>
);
}
return ( return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions // eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div <div
className="module-calling-pip" className="module-calling-pip"
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseDown={ev => { onMouseDown={ev => {
const node = videoContainerRef.current; const node = videoContainerRef.current;
if (!node) { if (!node) {
return; return;
} }
const targetNode = ev.target as Element;
if (targetNode?.tagName === 'BUTTON') {
return;
}
const parentNode = targetNode.parentNode as Element;
if (parentNode?.tagName === 'BUTTON') {
return;
}
const rect = node.getBoundingClientRect(); const rect = node.getBoundingClientRect();
const dragOffsetX = ev.clientX - rect.left; const dragOffsetX = ev.clientX - rect.left;
const dragOffsetY = ev.clientY - rect.top; const dragOffsetY = ev.clientY - rect.top;
@ -289,6 +448,7 @@ export function CallingPip({
}} }}
ref={videoContainerRef} ref={videoContainerRef}
style={{ style={{
height: `${height}px`,
cursor: cursor:
positionState.mode === PositionMode.BeingDragged positionState.mode === PositionMode.BeingDragged
? '-webkit-grabbing' ? '-webkit-grabbing'
@ -300,32 +460,96 @@ export function CallingPip({
: 'transform ease-out 300ms', : 'transform ease-out 300ms',
}} }}
> >
<CallingPipRemoteVideo {remoteVideoNode}
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource} {!isLonelyInCall && activeCall.hasLocalVideo ? (
imageDataCache={imageDataCache}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
setGroupCallVideoRequest={setGroupCallVideoRequest}
/>
{hasLocalVideo ? (
<div className={localVideoClassName} ref={setLocalPreviewContainer} /> <div className={localVideoClassName} ref={setLocalPreviewContainer} />
) : null} ) : null}
<div className="module-calling-pip__actions">
<button <div
aria-label={i18n('icu:calling__hangup')} className={classNames(
className="module-calling-pip__button--hangup" 'module-calling-pip__un-pip-container',
onClick={hangUp} showControls
type="button" ? 'module-calling-pip__un-pip-container--visible'
/> : undefined
<button )}
aria-label={i18n('icu:calling__pip--off')} >
className="module-calling-pip__button--pip" <CallingButton
buttonType={CallingButtonType.FULL_SCREEN_CALL}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={togglePip} onClick={togglePip}
type="button" tooltipDirection={TooltipPlacement.Top}
/>
</div>
{raisedHandsCount || callJoinRequests ? (
<div
className={classNames(
'module-calling-pip__pills',
!showControls ? 'module-calling-pip__pills--no-controls' : undefined
)}
> >
<div /> {raisedHandsCount ? (
</button> <div className="module-calling-pip__pill">
<div
className={classNames(
'module-calling-pip__pill-icon',
'module-calling-pip__pill-icon__raised-hands'
)}
/>
{raisedHandsCount}
</div>
) : undefined}
{callJoinRequests ? (
<div className="module-calling-pip__pill">
<div
className={classNames(
'module-calling-pip__pill-icon',
'module-calling-pip__pill-icon__group-join'
)}
/>
{callJoinRequests}
</div>
) : undefined}
</div>
) : undefined}
<div
className={classNames(
'module-calling-pip__actions',
showControls ? 'module-calling-pip__actions--visible' : undefined
)}
>
<div className="module-calling-pip__actions__button">
<CallingButton
buttonType={videoButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleVideo}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="module-calling-pip__actions__button module-calling-pip__actions__middle-button">
<CallingButton
buttonType={audioButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={toggleAudio}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
<div className="module-calling-pip__actions__button">
<CallingButton
buttonType={hangupButtonType}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
onClick={hangUp}
tooltipDirection={TooltipPlacement.Top}
/>
</div>
</div> </div>
</div> </div>
); );

View file

@ -1,25 +1,25 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useMemo, useEffect } from 'react'; import React, { useEffect } from 'react';
import { clamp, maxBy } from 'lodash'; import { clamp, isNumber, maxBy } from 'lodash';
import type { VideoFrameSource } from '@signalapp/ringrtc'; import type { VideoFrameSource } from '@signalapp/ringrtc';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import { CallBackgroundBlur } from './CallBackgroundBlur'; import { CallBackgroundBlur } from './CallBackgroundBlur';
import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant'; import { DirectCallRemoteParticipant } from './DirectCallRemoteParticipant';
import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant'; import { GroupCallRemoteParticipant } from './GroupCallRemoteParticipant';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import type { import {
ActiveCallType, GroupCallJoinState,
GroupCallRemoteParticipantType, type ActiveCallType,
GroupCallVideoRequest, type GroupCallRemoteParticipantType,
type GroupCallVideoRequest,
} from '../types/Calling'; } from '../types/Calling';
import { GroupCallJoinState } from '../types/Calling';
import { CallMode } from '../types/CallDisposition'; import { CallMode } from '../types/CallDisposition';
import { AvatarColors } from '../types/Colors'; import { AvatarColors } from '../types/Colors';
import type { SetRendererCanvasType } from '../state/ducks/calling'; import type { SetRendererCanvasType } from '../state/ducks/calling';
import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer'; import { useGetCallingFrameBuffer } from '../calling/useGetCallingFrameBuffer';
import { MAX_FRAME_WIDTH } from '../calling/constants'; import { MAX_FRAME_HEIGHT } from '../calling/constants';
import { usePageVisibility } from '../hooks/usePageVisibility'; import { usePageVisibility } from '../hooks/usePageVisibility';
import { missingCaseError } from '../util/missingCaseError'; import { missingCaseError } from '../util/missingCaseError';
import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant'; import { nonRenderedRemoteParticipant } from '../util/ringrtc/nonRenderedRemoteParticipant';
@ -27,21 +27,19 @@ import { isReconnecting } from '../util/callingIsReconnecting';
import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall'; import { isGroupOrAdhocActiveCall } from '../util/isGroupOrAdhocCall';
import { assertDev } from '../util/assert'; import { assertDev } from '../util/assert';
import type { CallingImageDataCache } from './CallManager'; import type { CallingImageDataCache } from './CallManager';
import { PIP_MAXIMUM_HEIGHT, PIP_MINIMUM_HEIGHT } from './CallingPip';
// This value should be kept in sync with the hard-coded CSS height. It should also be function BlurredBackground({
// less than `MAX_FRAME_HEIGHT`.
const PIP_VIDEO_HEIGHT_PX = 120;
function NoVideo({
activeCall, activeCall,
activeGroupCallSpeaker,
i18n, i18n,
}: { }: {
activeCall: ActiveCallType; activeCall: ActiveCallType;
activeGroupCallSpeaker?: undefined | GroupCallRemoteParticipantType;
i18n: LocalizerType; i18n: LocalizerType;
}): JSX.Element { }): JSX.Element {
const { const {
avatarPlaceholderGradient, avatarPlaceholderGradient,
avatarUrl,
color, color,
type: conversationType, type: conversationType,
phoneNumber, phoneNumber,
@ -49,28 +47,28 @@ function NoVideo({
sharedGroupNames, sharedGroupNames,
title, title,
} = activeCall.conversation; } = activeCall.conversation;
const avatarUrl =
activeGroupCallSpeaker?.avatarUrl ?? activeCall.conversation.avatarUrl;
return ( return (
<div className="module-calling-pip__video--remote"> <CallBackgroundBlur avatarUrl={avatarUrl}>
<CallBackgroundBlur avatarUrl={avatarUrl}> <div className="module-calling-pip__video--avatar">
<div className="module-calling-pip__video--avatar"> <Avatar
<Avatar avatarPlaceholderGradient={avatarPlaceholderGradient}
avatarPlaceholderGradient={avatarPlaceholderGradient} avatarUrl={avatarUrl}
avatarUrl={avatarUrl} badge={undefined}
badge={undefined} color={color || AvatarColors[0]}
color={color || AvatarColors[0]} noteToSelf={false}
noteToSelf={false} conversationType={conversationType}
conversationType={conversationType} i18n={i18n}
i18n={i18n} phoneNumber={phoneNumber}
phoneNumber={phoneNumber} profileName={profileName}
profileName={profileName} title={title}
title={title} size={AvatarSize.FORTY_EIGHT}
size={AvatarSize.FORTY_EIGHT} sharedGroupNames={sharedGroupNames}
sharedGroupNames={sharedGroupNames} />
/> </div>
</div> </CallBackgroundBlur>
</CallBackgroundBlur>
</div>
); );
} }
@ -84,6 +82,9 @@ export type PropsType = {
speakerHeight: number speakerHeight: number
) => void; ) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void;
height: number;
width: number;
updateHeight: (newHeight: number) => void;
}; };
export function CallingPipRemoteVideo({ export function CallingPipRemoteVideo({
@ -93,6 +94,9 @@ export function CallingPipRemoteVideo({
i18n, i18n,
setGroupCallVideoRequest, setGroupCallVideoRequest,
setRendererCanvas, setRendererCanvas,
height,
width,
updateHeight,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const { conversation } = activeCall; const { conversation } = activeCall;
@ -101,7 +105,7 @@ export function CallingPipRemoteVideo({
const isPageVisible = usePageVisibility(); const isPageVisible = usePageVisibility();
const activeGroupCallSpeaker: undefined | GroupCallRemoteParticipantType = const activeGroupCallSpeaker: undefined | GroupCallRemoteParticipantType =
useMemo(() => { React.useMemo(() => {
if (!isGroupOrAdhocActiveCall(activeCall)) { if (!isGroupOrAdhocActiveCall(activeCall)) {
return undefined; return undefined;
} }
@ -116,53 +120,83 @@ export function CallingPipRemoteVideo({
}, [activeCall]); }, [activeCall]);
useEffect(() => { useEffect(() => {
if (!isGroupOrAdhocActiveCall(activeCall)) { if (isGroupOrAdhocActiveCall(activeCall)) {
return; if (!activeGroupCallSpeaker || !activeGroupCallSpeaker.hasRemoteVideo) {
} return;
}
const { videoAspectRatio } = activeGroupCallSpeaker;
if (!isNumber(videoAspectRatio)) {
return;
}
if (isPageVisible) { const newHeight = clamp(
setGroupCallVideoRequest( Math.floor(width * (1 / videoAspectRatio)),
activeCall.remoteParticipants.map(participant => { 1,
MAX_FRAME_HEIGHT
);
// Update only for portrait video that fits, otherwise leave things as they are
if (
newHeight !== height &&
newHeight >= PIP_MINIMUM_HEIGHT &&
newHeight <= PIP_MAXIMUM_HEIGHT
) {
updateHeight(newHeight);
}
if (isPageVisible) {
const participants = activeCall.remoteParticipants.map(participant => {
if (participant === activeGroupCallSpeaker) { if (participant === activeGroupCallSpeaker) {
return { return {
demuxId: participant.demuxId, demuxId: participant.demuxId,
width: clamp( width,
Math.floor(PIP_VIDEO_HEIGHT_PX * participant.videoAspectRatio), height: newHeight,
1,
MAX_FRAME_WIDTH
),
height: PIP_VIDEO_HEIGHT_PX,
}; };
} }
return nonRenderedRemoteParticipant(participant); return nonRenderedRemoteParticipant(participant);
}), });
PIP_VIDEO_HEIGHT_PX setGroupCallVideoRequest(participants, newHeight);
); } else {
setGroupCallVideoRequest(
activeCall.remoteParticipants.map(nonRenderedRemoteParticipant),
0
);
}
} else { } else {
setGroupCallVideoRequest( // eslint-disable-next-line no-lonely-if
activeCall.remoteParticipants.map(nonRenderedRemoteParticipant), if (!activeCall.hasRemoteVideo) {
0 // eslint-disable-next-line no-useless-return
); return;
}
// TODO: DESKTOP-8537 - with direct call video stats, call updateHeight as needed
} }
}, [ }, [
activeCall, activeCall,
activeGroupCallSpeaker, activeGroupCallSpeaker,
height,
isPageVisible, isPageVisible,
setGroupCallVideoRequest, setGroupCallVideoRequest,
updateHeight,
width,
]); ]);
switch (activeCall.callMode) { switch (activeCall.callMode) {
case CallMode.Direct: { case CallMode.Direct: {
const { hasRemoteVideo } = activeCall.remoteParticipants[0]; const { hasRemoteVideo } = activeCall.remoteParticipants[0];
if (!hasRemoteVideo) { if (!hasRemoteVideo) {
return <NoVideo activeCall={activeCall} i18n={i18n} />; return (
<div className="module-calling-pip__video--remote">
<BlurredBackground activeCall={activeCall} i18n={i18n} />
</div>
);
} }
assertDev( assertDev(
conversation.type === 'direct', conversation.type === 'direct',
'CallingPipRemoteVideo for direct call must be associated with direct conversation' 'CallingPipRemoteVideo for direct call must be associated with direct conversation'
); );
// TODO: DESKTOP-8537 - when black bars go away, we need to make some CSS changes
return ( return (
<div className="module-calling-pip__video--remote"> <div className="module-calling-pip__video--remote">
<BlurredBackground activeCall={activeCall} i18n={i18n} />
<DirectCallRemoteParticipant <DirectCallRemoteParticipant
conversation={conversation} conversation={conversation}
hasRemoteVideo={hasRemoteVideo} hasRemoteVideo={hasRemoteVideo}
@ -176,10 +210,19 @@ export function CallingPipRemoteVideo({
case CallMode.Group: case CallMode.Group:
case CallMode.Adhoc: case CallMode.Adhoc:
if (!activeGroupCallSpeaker) { if (!activeGroupCallSpeaker) {
return <NoVideo activeCall={activeCall} i18n={i18n} />; return (
<div className="module-calling-pip__video--remote">
<BlurredBackground activeCall={activeCall} i18n={i18n} />
</div>
);
} }
return ( return (
<div className="module-calling-pip__video--remote"> <div className="module-calling-pip__video--remote">
<BlurredBackground
activeCall={activeCall}
activeGroupCallSpeaker={activeGroupCallSpeaker}
i18n={i18n}
/>
<GroupCallRemoteParticipant <GroupCallRemoteParticipant
getFrameBuffer={getGroupCallFrameBuffer} getFrameBuffer={getGroupCallFrameBuffer}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource} getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}

View file

@ -356,13 +356,19 @@ export type RemoveClientType = ReadonlyDeep<{
demuxId: number; demuxId: number;
}>; }>;
export type SetLocalAudioType = ReadonlyDeep<{ // eslint-disable-next-line local-rules/type-alias-readonlydeep
enabled: boolean; export type SetLocalAudioType = (
}>; payload?: ReadonlyDeep<{
enabled: boolean;
}>
) => void;
export type SetLocalVideoType = ReadonlyDeep<{ // eslint-disable-next-line local-rules/type-alias-readonlydeep
enabled: boolean; export type SetLocalVideoType = (
}>; payload: ReadonlyDeep<{
enabled: boolean;
}>
) => void;
export type SetGroupCallVideoRequestType = ReadonlyDeep<{ export type SetGroupCallVideoRequestType = ReadonlyDeep<{
conversationId: string; conversationId: string;
@ -901,12 +907,12 @@ type SelectPresentingSourceActionType = ReadonlyDeep<{
type SetLocalAudioActionType = ReadonlyDeep<{ type SetLocalAudioActionType = ReadonlyDeep<{
type: 'calling/SET_LOCAL_AUDIO_FULFILLED'; type: 'calling/SET_LOCAL_AUDIO_FULFILLED';
payload: SetLocalAudioType; payload: Parameters<SetLocalAudioType>[0];
}>; }>;
type SetLocalVideoFulfilledActionType = ReadonlyDeep<{ type SetLocalVideoFulfilledActionType = ReadonlyDeep<{
type: 'calling/SET_LOCAL_VIDEO_FULFILLED'; type: 'calling/SET_LOCAL_VIDEO_FULFILLED';
payload: SetLocalVideoType; payload: Parameters<SetLocalVideoType>[0];
}>; }>;
type SetPresentingFulfilledActionType = ReadonlyDeep<{ type SetPresentingFulfilledActionType = ReadonlyDeep<{
@ -1903,26 +1909,28 @@ function setRendererCanvas(
} }
function setLocalAudio( function setLocalAudio(
payload: SetLocalAudioType payload?: Parameters<SetLocalAudioType>[0]
): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> { ): ThunkAction<void, RootStateType, unknown, SetLocalAudioActionType> {
return (dispatch, getState) => { return (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling); const { activeCallState } = getState().calling;
if (!activeCall) { if (!activeCallState || activeCallState.state !== 'Active') {
log.warn('Trying to set local audio when no call is active'); log.warn('Trying to set local audio when no call is active');
return; return;
} }
calling.setOutgoingAudio(activeCall.conversationId, payload.enabled); const enabled = payload?.enabled ?? !activeCallState.hasLocalAudio;
calling.setOutgoingAudio(activeCallState.conversationId, enabled);
dispatch({ dispatch({
type: SET_LOCAL_AUDIO_FULFILLED, type: SET_LOCAL_AUDIO_FULFILLED,
payload, payload: {
enabled,
},
}); });
}; };
} }
function setLocalVideo( function setLocalVideo(
payload: SetLocalVideoType payload: Parameters<SetLocalVideoType>[0]
): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> { ): ThunkAction<void, RootStateType, unknown, SetLocalVideoFulfilledActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const activeCall = getActiveCall(getState().calling); const activeCall = getActiveCall(getState().calling);
@ -1931,7 +1939,7 @@ function setLocalVideo(
return; return;
} }
let enabled: boolean; let enabled = payload?.enabled;
if (await requestCameraPermissions()) { if (await requestCameraPermissions()) {
if ( if (
isGroupOrAdhocCallState(activeCall) || isGroupOrAdhocCallState(activeCall) ||
@ -1939,14 +1947,13 @@ function setLocalVideo(
) { ) {
await calling.setOutgoingVideo( await calling.setOutgoingVideo(
activeCall.conversationId, activeCall.conversationId,
payload.enabled Boolean(payload?.enabled)
); );
} else if (payload.enabled) { } else if (payload?.enabled) {
await calling.enableLocalCamera(activeCall.callMode); await calling.enableLocalCamera(activeCall.callMode);
} else { } else {
calling.disableLocalVideo(); calling.disableLocalVideo();
} }
({ enabled } = payload);
} else { } else {
enabled = false; enabled = false;
} }
@ -1954,8 +1961,7 @@ function setLocalVideo(
dispatch({ dispatch({
type: SET_LOCAL_VIDEO_FULFILLED, type: SET_LOCAL_VIDEO_FULFILLED,
payload: { payload: {
...payload, enabled: Boolean(enabled),
enabled,
}, },
}); });
}; };
@ -3994,7 +4000,7 @@ export function reducer(
...state, ...state,
activeCallState: { activeCallState: {
...state.activeCallState, ...state.activeCallState,
hasLocalAudio: action.payload.enabled, hasLocalAudio: Boolean(action.payload?.enabled),
}, },
}; };
} }
@ -4009,7 +4015,7 @@ export function reducer(
...state, ...state,
activeCallState: { activeCallState: {
...state.activeCallState, ...state.activeCallState,
hasLocalVideo: action.payload.enabled, hasLocalVideo: Boolean(action.payload?.enabled),
}, },
}; };
} }

View file

@ -0,0 +1,34 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { sample } from 'lodash';
import { AvatarColors } from '../../types/Colors';
import type { GroupCallRemoteParticipantType } from '../../types/Calling';
import { generateAci } from '../../types/ServiceId';
import { getDefaultConversationWithServiceId } from './getDefaultConversation';
export function createCallParticipant(
participantProps: Partial<GroupCallRemoteParticipantType>
): GroupCallRemoteParticipantType {
return {
aci: generateAci(),
demuxId: 2,
hasRemoteAudio: Boolean(participantProps.hasRemoteAudio),
hasRemoteVideo: Boolean(participantProps.hasRemoteVideo),
isHandRaised: Boolean(participantProps.isHandRaised),
mediaKeysReceived: Boolean(participantProps.mediaKeysReceived),
presenting: Boolean(participantProps.presenting),
sharingScreen: Boolean(participantProps.sharingScreen),
videoAspectRatio: 1.3,
...getDefaultConversationWithServiceId({
avatarUrl: participantProps.avatarUrl,
color: sample(AvatarColors),
isBlocked: Boolean(participantProps.isBlocked),
name: participantProps.name,
profileName: participantProps.title,
title: String(participantProps.title),
}),
};
}