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
parent efffc4f569
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",
"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": {
"messageformat": "Mic off",
"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-version": "node scripts/prepare_tagged_version.js adhoc",
"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-electron": "node ts/scripts/test-electron.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 {
align-items: center;
position: absolute;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
flex-direction: column;
height: 100%;
justify-content: center;
overflow: hidden;
position: relative;
width: 100%;
&--blur {
background-repeat: no-repeat;
@ -3989,9 +3992,10 @@ button.module-image__border-overlay:focus {
}
.module-ongoing-call {
&__remote-video-enabled {
background-color: variables.$color-gray-95;
// TODO: DESKTOP-8537 remove this; we want blurred avatar not all-black letterboxing
height: 100%;
width: 100%;
position: relative;
&--reconnecting {
filter: blur(15px);
}
@ -4002,6 +4006,7 @@ button.module-image__border-overlay:focus {
height: 100vh;
width: 100%;
display: flex;
position: relative;
align-items: 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
// for implicitly sized participants (PiP)
container-type: size;
position: relative;
@container (min-width: 180px) or (min-height: 180px) {
.module-ongoing-call__group-call-remote-participant__footer {
@ -4625,14 +4631,17 @@ button.module-image__border-overlay:focus {
.module-calling-pip {
backface-visibility: hidden;
background-color: variables.$color-gray-95;
border-radius: 4px;
border-radius: 18px;
box-shadow:
0px 0px 8px rgba(0, 0, 0, 0.05),
0px 8px 20px rgba(0, 0, 0, 0.3);
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;
width: 120px;
z-index: variables.$z-index-calling-pip;
& .module-ongoing-call__group-call-remote-participant {
@ -4643,13 +4652,13 @@ button.module-image__border-overlay:focus {
&--remote {
align-items: center;
background-color: variables.$color-gray-95;
border-radius: 4px 4px 0 0;
border-radius: 18px;
height: 100%;
width: 100%;
display: flex;
height: 120px; // This height should be kept in sync with <CallingPipRemoteVideo>'s hard-coded height.
justify-content: center;
overflow: hidden;
position: relative;
width: 100%;
// The avatar image can be dragged on Windows.
.module-Avatar img {
@ -4664,15 +4673,20 @@ button.module-image__border-overlay:focus {
&--local,
&--local-presenting {
bottom: 38px;
height: 32px;
position: absolute;
inset-inline-end: 4px;
width: 32px;
top: 8px;
inset-inline-start: 8px;
height: 54px;
width: 80px;
border-radius: 12px;
overflow: hidden;
background-color: variables.$color-gray-80;
video {
width: 100%;
height: 100%;
object-fit: cover;
}
}
@ -4681,17 +4695,116 @@ button.module-image__border-overlay:focus {
}
}
&__actions {
align-items: center;
background-color: variables.$color-gray-02;
border-radius: 0 0 4px 4px;
&__full-size-local-preview {
width: 100%;
position: relative;
video {
width: 100%;
transform: rotateY(180deg);
}
&--presenting {
transform: none;
}
}
&__pills {
display: flex;
flex-direction: row;
height: 38px;
justify-content: space-around;
align-items: center;
@include mixins.dark-theme {
background-color: variables.$color-gray-65;
position: absolute;
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 {
@include calling-button-icon(
'../images/icons/v3/phone/phone-down-fill-light.svg',
@ -264,3 +272,9 @@
width: 16px;
}
}
.module-calling-pip {
.CallingButton__button-container {
margin: 0;
}
}

View file

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

View file

@ -122,8 +122,8 @@ export type PropsType = {
_: Array<GroupCallVideoRequest>,
speakerHeight: number
) => void;
setLocalAudio: (_: SetLocalAudioType) => void;
setLocalVideo: (_: SetLocalVideoType) => void;
setLocalAudio: SetLocalAudioType;
setLocalVideo: SetLocalVideoType;
setLocalPreviewContainer: (container: HTMLDivElement | null) => void;
setRendererCanvas: (_: SetRendererCanvasType) => void;
stickyControls: boolean;
@ -489,10 +489,7 @@ export function CallScreen({
)}
>
{isSendingVideo ? (
<div
className="module-ongoing-call__local-preview-container"
ref={setLocalPreviewContainer}
/>
<div ref={setLocalPreviewContainer} />
) : (
<CallBackgroundBlur avatarUrl={me.avatarUrl}>
<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_OFF = 'AUDIO_OFF',
AUDIO_ON = 'AUDIO_ON',
FULL_SCREEN_CALL = 'FULL_SCREEN_CALL',
HANGUP_GROUP = 'HANGUP_GROUP',
HANGUP_DIRECT = 'HANGUP_DIRECT',
MAXIMIZE = 'MAXIMIZE',
MINIMIZE = 'MINIMIZE',
MORE_OPTIONS = 'MORE_OPTIONS',
@ -117,6 +120,19 @@ export function CallingButton({
} else if (buttonType === CallingButtonType.MINIMIZE) {
classNameSuffix = '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(

View file

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

View file

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

View file

@ -20,9 +20,18 @@ import { CallMode } from '../types/CallDisposition';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import { fakeGetGroupCallVideoFrameSource } from '../test-both/helpers/fakeGetGroupCallVideoFrameSource';
import { MINUTE } from '../util/durations';
import type { SetRendererCanvasType } from '../state/ducks/calling';
import { createCallParticipant } from '../test-both/helpers/createCallParticipant';
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({
id: '3051234567',
avatarUrl: undefined,
@ -43,7 +52,7 @@ type Overrides = {
const getCommonActiveCallData = (overrides: Overrides) => ({
conversation,
hasLocalAudio: overrides.hasLocalAudio ?? true,
hasLocalVideo: overrides.hasLocalVideo ?? false,
hasLocalVideo: overrides.hasLocalVideo ?? true,
localAudioLevel: overrides.localAudioLevel ?? 0,
viewMode: overrides.viewMode ?? CallViewMode.Paginated,
joinedAt: Date.now() - MINUTE,
@ -71,21 +80,27 @@ const getDefaultCall = (overrides: Overrides): ActiveDirectCallType => {
export default {
title: 'Components/CallingPip',
argTypes: {
hasLocalVideo: { control: { type: 'boolean' } },
},
args: {
activeCall: getDefaultCall({}),
getGroupCallVideoFrameSource: fakeGetGroupCallVideoFrameSource,
hangUpActiveCall: action('hang-up-active-call'),
hasLocalVideo: false,
i18n,
me: getDefaultConversation({
name: 'Lonely InGroup',
title: 'Lonely InGroup',
}),
setGroupCallVideoRequest: action('set-group-call-video-request'),
setLocalPreviewContainer: action('set-local-preview-container'),
setRendererCanvas: action('set-renderer-canvas'),
setLocalPreviewContainer: (container: HTMLDivElement | null) => {
container?.appendChild(localPreviewVideo);
},
setRendererCanvas: ({ element }: SetRendererCanvasType) => {
element?.current?.getContext('2d')?.drawImage(videoScreenshot, 0, 0);
},
switchFromPresentationView: action('switch-to-presentation-view'),
switchToPresentationView: action('switch-to-presentation-view'),
toggleAudio: action('toggle-audio'),
togglePip: action('toggle-pip'),
toggleVideo: action('toggle-video'),
},
} satisfies Meta<PropsType>;
@ -93,6 +108,60 @@ export function Default(args: PropsType): JSX.Element {
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 {
return (
<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 (
<CallingPip
{...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
import React from 'react';
import classNames from 'classnames';
import { minBy, debounce, noop } from 'lodash';
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 { CallBackgroundBlur } from './CallBackgroundBlur';
import type { LocalizerType } from '../types/Util';
import type { ActiveCallType, GroupCallVideoRequest } from '../types/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 { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize } from './Avatar';
import { AvatarColors } from '../types/Colors';
enum PositionMode {
BeingDragged,
@ -50,9 +62,21 @@ export type PropsType = {
activeCall: ActiveCallType;
getGroupCallVideoFrameSource: (demuxId: number) => VideoFrameSource;
hangUpActiveCall: (reason: string) => void;
hasLocalVideo: boolean;
i18n: LocalizerType;
imageDataCache: React.RefObject<CallingImageDataCache>;
me: Readonly<
Pick<
ConversationType,
| 'avatarUrl'
| 'avatarPlaceholderGradient'
| 'color'
| 'type'
| 'phoneNumber'
| 'profileName'
| 'title'
| 'sharedGroupNames'
>
>;
setGroupCallVideoRequest: (
_: Array<GroupCallVideoRequest>,
speakerHeight: number
@ -61,32 +85,42 @@ export type PropsType = {
setRendererCanvas: (_: SetRendererCanvasType) => void;
switchToPresentationView: () => void;
switchFromPresentationView: () => void;
toggleAudio: () => void;
togglePip: () => void;
toggleVideo: () => void;
};
const PIP_HEIGHT = 156;
const PIP_WIDTH = 120;
const PIP_TOP_MARGIN = 56;
const PIP_STARTING_HEIGHT = 286;
const PIP_WIDTH = 160;
const PIP_TOP_MARGIN = 78;
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({
activeCall,
getGroupCallVideoFrameSource,
hangUpActiveCall,
hasLocalVideo,
imageDataCache,
i18n,
me,
setGroupCallVideoRequest,
setLocalPreviewContainer,
setRendererCanvas,
switchToPresentationView,
switchFromPresentationView,
toggleAudio,
togglePip,
toggleVideo,
}: PropsType): JSX.Element {
const isRTL = i18n.getLocaleDirection() === 'rtl';
const videoContainerRef = React.useRef<null | HTMLDivElement>(null);
const [height, setHeight] = React.useState(PIP_STARTING_HEIGHT);
const [windowWidth, setWindowWidth] = React.useState(window.innerWidth);
const [windowHeight, setWindowHeight] = React.useState(window.innerHeight);
const [positionState, setPositionState] = React.useState<PositionState>({
@ -112,6 +146,8 @@ export function CallingPip({
mouseX: ev.clientX,
mouseY: ev.clientY,
}));
ev.preventDefault();
ev.stopPropagation();
}
},
[positionState]
@ -150,7 +186,7 @@ export function CallingPip({
},
{
mode: PositionMode.SnapToBottom,
distanceToEdge: innerHeight - (offsetY + PIP_HEIGHT),
distanceToEdge: innerHeight - (offsetY + height),
},
];
@ -179,7 +215,7 @@ export function CallingPip({
throw missingCaseError(snapTo.mode);
}
}
}, [isRTL, positionState, setPositionState]);
}, [height, isRTL, positionState, setPositionState]);
React.useEffect(() => {
if (positionState.mode === PositionMode.BeingDragged) {
@ -213,6 +249,15 @@ export function CallingPip({
}, []);
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) {
case PositionMode.BeingDragged:
return [
@ -225,56 +270,170 @@ export function CallingPip({
];
case PositionMode.SnapToLeft:
return [
PIP_PADDING,
Math.min(
positionState.offsetY,
windowHeight - PIP_PADDING - PIP_HEIGHT
),
leftMin,
Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
];
case PositionMode.SnapToRight:
return [
windowWidth - PIP_PADDING - PIP_WIDTH,
Math.min(
positionState.offsetY,
windowHeight - PIP_PADDING - PIP_HEIGHT
),
rightMax,
Math.max(topMin, Math.min(positionState.offsetY, bottomMax)),
];
case PositionMode.SnapToTop:
return [
Math.min(
positionState.offsetX,
windowWidth - PIP_PADDING - PIP_WIDTH
),
PIP_TOP_MARGIN + PIP_PADDING,
Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
topMin,
];
case PositionMode.SnapToBottom:
return [
Math.min(
positionState.offsetX,
windowWidth - PIP_PADDING - PIP_WIDTH
),
windowHeight - PIP_PADDING - PIP_HEIGHT,
Math.max(leftMin, Math.min(positionState.offsetX, rightMax)),
bottomMax,
];
default:
throw missingCaseError(positionState);
}
}, [isRTL, windowWidth, windowHeight, positionState]);
}, [height, isRTL, windowWidth, windowHeight, positionState]);
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
? 'module-calling-pip__video--local-presenting'
: '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 (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<div
className="module-calling-pip"
onMouseEnter={onMouseEnter}
onMouseMove={onMouseMove}
onMouseDown={ev => {
const node = videoContainerRef.current;
if (!node) {
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 dragOffsetX = ev.clientX - rect.left;
const dragOffsetY = ev.clientY - rect.top;
@ -289,6 +448,7 @@ export function CallingPip({
}}
ref={videoContainerRef}
style={{
height: `${height}px`,
cursor:
positionState.mode === PositionMode.BeingDragged
? '-webkit-grabbing'
@ -300,32 +460,96 @@ export function CallingPip({
: 'transform ease-out 300ms',
}}
>
<CallingPipRemoteVideo
activeCall={activeCall}
getGroupCallVideoFrameSource={getGroupCallVideoFrameSource}
imageDataCache={imageDataCache}
i18n={i18n}
setRendererCanvas={setRendererCanvas}
setGroupCallVideoRequest={setGroupCallVideoRequest}
/>
{hasLocalVideo ? (
{remoteVideoNode}
{!isLonelyInCall && activeCall.hasLocalVideo ? (
<div className={localVideoClassName} ref={setLocalPreviewContainer} />
) : null}
<div className="module-calling-pip__actions">
<button
aria-label={i18n('icu:calling__hangup')}
className="module-calling-pip__button--hangup"
onClick={hangUp}
type="button"
/>
<button
aria-label={i18n('icu:calling__pip--off')}
className="module-calling-pip__button--pip"
<div
className={classNames(
'module-calling-pip__un-pip-container',
showControls
? 'module-calling-pip__un-pip-container--visible'
: undefined
)}
>
<CallingButton
buttonType={CallingButtonType.FULL_SCREEN_CALL}
i18n={i18n}
onMouseEnter={onControlsMouseEnter}
onMouseLeave={onControlsMouseLeave}
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 />
</button>
{raisedHandsCount ? (
<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>
);

View file

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

View file

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