Skip to content

Commit c87ffcd

Browse files
Call lobby: render local preview at camera's aspect ratio
1 parent 819f5f3 commit c87ffcd

File tree

5 files changed

+189
-52
lines changed

5 files changed

+189
-52
lines changed

stylesheets/_modules.scss

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6535,28 +6535,56 @@ button.module-image__border-overlay:focus {
65356535
}
65366536
}
65376537

6538-
&__video {
6538+
// The dimensions of this element are set by JavaScript.
6539+
&__local-preview {
6540+
$transition: 200ms ease-out;
6541+
65396542
@include font-body-2;
65406543
border-radius: 8px;
65416544
color: $color-white;
65426545
display: flex;
65436546
flex-direction: column;
6544-
flex: 1 1 auto;
6545-
margin-bottom: 24px;
6546-
margin-top: 24px;
6547-
max-width: 640px;
6547+
max-height: 100%;
6548+
max-width: 100%;
65486549
overflow: hidden;
65496550
position: relative;
6550-
width: 100%;
6551-
}
6551+
transition: width $transition, height $transition;
6552+
6553+
&-container {
6554+
align-items: center;
6555+
display: flex;
6556+
flex-direction: column;
6557+
flex: 1 1 auto;
6558+
justify-content: center;
6559+
margin: 24px;
6560+
overflow: hidden;
6561+
width: 90%;
6562+
}
65526563

6553-
&__video-on {
6554-
&__video {
6564+
&__video-on {
6565+
background-color: $color-gray-80;
65556566
display: block;
65566567
flex-grow: 1;
6568+
object-fit: contain;
65576569
transform: rotateY(180deg);
65586570
width: 100%;
6559-
background-color: $color-gray-80;
6571+
height: 100%;
6572+
}
6573+
6574+
&__video-off {
6575+
&__icon {
6576+
@include color-svg(
6577+
'../images/icons/v2/video-off-solid-24.svg',
6578+
$color-white
6579+
);
6580+
height: 24px;
6581+
margin-bottom: 8px;
6582+
width: 24px;
6583+
}
6584+
6585+
&__text {
6586+
z-index: 1;
6587+
}
65606588
}
65616589
}
65626590

ts/calling/constants.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Copyright 2020 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
export const REQUESTED_VIDEO_WIDTH = 640;
5+
export const REQUESTED_VIDEO_HEIGHT = 480;
6+
export const REQUESTED_VIDEO_FRAMERATE = 30;

ts/components/CallingLobby.tsx

Lines changed: 134 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// SPDX-License-Identifier: AGPL-3.0-only
33

44
import React, { ReactNode } from 'react';
5+
import Measure from 'react-measure';
6+
import { debounce } from 'lodash';
57
import {
68
SetLocalAudioType,
79
SetLocalPreviewType,
@@ -15,6 +17,15 @@ import { Spinner } from './Spinner';
1517
import { ColorType } from '../types/Colors';
1618
import { LocalizerType } from '../types/Util';
1719
import { ConversationType } from '../state/ducks/conversations';
20+
import {
21+
REQUESTED_VIDEO_WIDTH,
22+
REQUESTED_VIDEO_HEIGHT,
23+
} from '../calling/constants';
24+
25+
// We request dimensions but may not get them depending on the user's webcam. This is our
26+
// fallback while we don't know.
27+
const VIDEO_ASPECT_RATIO_FALLBACK =
28+
REQUESTED_VIDEO_WIDTH / REQUESTED_VIDEO_HEIGHT;
1829

1930
export type PropsType = {
2031
availableCameras: Array<MediaDeviceInfo>;
@@ -61,7 +72,18 @@ export const CallingLobby = ({
6172
toggleParticipants,
6273
toggleSettings,
6374
}: PropsType): JSX.Element => {
64-
const localVideoRef = React.useRef(null);
75+
const [
76+
localPreviewContainerWidth,
77+
setLocalPreviewContainerWidth,
78+
] = React.useState<null | number>(null);
79+
const [
80+
localPreviewContainerHeight,
81+
setLocalPreviewContainerHeight,
82+
] = React.useState<null | number>(null);
83+
const [localVideoAspectRatio, setLocalVideoAspectRatio] = React.useState(
84+
VIDEO_ASPECT_RATIO_FALLBACK
85+
);
86+
const localVideoRef = React.useRef<null | HTMLVideoElement>(null);
6587

6688
const toggleAudio = React.useCallback((): void => {
6789
setLocalAudio({ enabled: !hasLocalAudio });
@@ -71,6 +93,24 @@ export const CallingLobby = ({
7193
setLocalVideo({ enabled: !hasLocalVideo });
7294
}, [hasLocalVideo, setLocalVideo]);
7395

96+
const hasEverMeasured =
97+
localPreviewContainerWidth !== null && localPreviewContainerHeight !== null;
98+
const setLocalPreviewContainerDimensions = React.useMemo(() => {
99+
const set = (bounds: Readonly<{ width: number; height: number }>) => {
100+
setLocalPreviewContainerWidth(bounds.width);
101+
setLocalPreviewContainerHeight(bounds.height);
102+
};
103+
104+
if (hasEverMeasured) {
105+
return debounce(set, 100, { maxWait: 3000 });
106+
}
107+
return set;
108+
}, [
109+
hasEverMeasured,
110+
setLocalPreviewContainerWidth,
111+
setLocalPreviewContainerHeight,
112+
]);
113+
74114
React.useEffect(() => {
75115
setLocalPreview({ element: localVideoRef });
76116

@@ -79,6 +119,21 @@ export const CallingLobby = ({
79119
};
80120
}, [setLocalPreview]);
81121

122+
// This isn't perfect because it doesn't react to changes in the webcam's aspect ratio.
123+
// For example, if you changed from Webcam A to Webcam B and Webcam B had a different
124+
// aspect ratio, we wouldn't update.
125+
//
126+
// Unfortunately, RingRTC (1) doesn't update these dimensions with the "real" camera
127+
// dimensions (2) doesn't give us any hooks or callbacks. For now, this works okay.
128+
// We have `object-fit: contain` in the CSS in case we're wrong; not ideal, but
129+
// usable.
130+
React.useEffect(() => {
131+
const videoEl = localVideoRef.current;
132+
if (hasLocalVideo && videoEl && videoEl.width && videoEl.height) {
133+
setLocalVideoAspectRatio(videoEl.width / videoEl.height);
134+
}
135+
}, [hasLocalVideo, setLocalVideoAspectRatio]);
136+
82137
React.useEffect(() => {
83138
function handleKeyDown(event: KeyboardEvent): void {
84139
let eventHandled = false;
@@ -141,6 +196,33 @@ export const CallingLobby = ({
141196
joinButtonChildren = i18n('calling__start');
142197
}
143198

199+
let localPreviewStyles: React.CSSProperties;
200+
// It'd be nice to use `hasEverMeasured` here, too, but TypeScript isn't smart enough
201+
// to understand the logic here.
202+
if (
203+
localPreviewContainerWidth !== null &&
204+
localPreviewContainerHeight !== null
205+
) {
206+
const containerAspectRatio =
207+
localPreviewContainerWidth / localPreviewContainerHeight;
208+
localPreviewStyles =
209+
containerAspectRatio < localVideoAspectRatio
210+
? {
211+
width: '100%',
212+
height: Math.floor(
213+
localPreviewContainerWidth / localVideoAspectRatio
214+
),
215+
}
216+
: {
217+
width: Math.floor(
218+
localPreviewContainerHeight * localVideoAspectRatio
219+
),
220+
height: '100%',
221+
};
222+
} else {
223+
localPreviewStyles = { display: 'none' };
224+
}
225+
144226
return (
145227
<div className="module-calling__container">
146228
<CallingHeader
@@ -153,37 +235,58 @@ export const CallingLobby = ({
153235
toggleSettings={toggleSettings}
154236
/>
155237

156-
<div className="module-calling-lobby__video">
157-
{hasLocalVideo && availableCameras.length > 0 ? (
158-
<video
159-
className="module-calling-lobby__video-on__video"
160-
ref={localVideoRef}
161-
autoPlay
162-
/>
163-
) : (
164-
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
165-
<div className="module-calling__video-off--icon" />
166-
<span className="module-calling__video-off--text">
167-
{i18n('calling__your-video-is-off')}
168-
</span>
169-
</CallBackgroundBlur>
170-
)}
238+
<Measure
239+
bounds
240+
onResize={({ bounds }) => {
241+
if (!bounds) {
242+
window.log.error('We should be measuring bounds');
243+
return;
244+
}
245+
setLocalPreviewContainerDimensions(bounds);
246+
}}
247+
>
248+
{({ measureRef }) => (
249+
<div
250+
ref={measureRef}
251+
className="module-calling-lobby__local-preview-container"
252+
>
253+
<div
254+
className="module-calling-lobby__local-preview"
255+
style={localPreviewStyles}
256+
>
257+
{hasLocalVideo && availableCameras.length > 0 ? (
258+
<video
259+
className="module-calling-lobby__local-preview__video-on"
260+
ref={localVideoRef}
261+
autoPlay
262+
/>
263+
) : (
264+
<CallBackgroundBlur avatarPath={me.avatarPath} color={me.color}>
265+
<div className="module-calling-lobby__local-preview__video-off__icon" />
266+
<span className="module-calling-lobby__local-preview__video-off__text">
267+
{i18n('calling__your-video-is-off')}
268+
</span>
269+
</CallBackgroundBlur>
270+
)}
171271

172-
<div className="module-calling__buttons">
173-
<CallingButton
174-
buttonType={videoButtonType}
175-
i18n={i18n}
176-
onClick={toggleVideo}
177-
tooltipDirection={TooltipPlacement.Top}
178-
/>
179-
<CallingButton
180-
buttonType={audioButtonType}
181-
i18n={i18n}
182-
onClick={toggleAudio}
183-
tooltipDirection={TooltipPlacement.Top}
184-
/>
185-
</div>
186-
</div>
272+
<div className="module-calling__buttons">
273+
<CallingButton
274+
buttonType={videoButtonType}
275+
i18n={i18n}
276+
onClick={toggleVideo}
277+
tooltipDirection={TooltipPlacement.Top}
278+
/>
279+
<CallingButton
280+
buttonType={audioButtonType}
281+
i18n={i18n}
282+
onClick={toggleAudio}
283+
tooltipDirection={TooltipPlacement.Top}
284+
/>
285+
</div>
286+
</div>
287+
</div>
288+
)}
289+
</Measure>
187290

188291
{isGroupCall ? (
189292
<div className="module-calling-lobby__info">

ts/services/calling.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,11 @@ import {
5858
} from '../groups';
5959
import { missingCaseError } from '../util/missingCaseError';
6060
import { normalizeGroupCallTimestamp } from '../util/ringrtc/normalizeGroupCallTimestamp';
61+
import {
62+
REQUESTED_VIDEO_WIDTH,
63+
REQUESTED_VIDEO_HEIGHT,
64+
REQUESTED_VIDEO_FRAMERATE,
65+
} from '../calling/constants';
6166

6267
const RINGRTC_HTTP_METHOD_TO_OUR_HTTP_METHOD: Map<
6368
HttpMethod,
@@ -103,7 +108,11 @@ export class CallingClass {
103108
private callsByConversation: { [conversationId: string]: Call | GroupCall };
104109

105110
constructor() {
106-
this.videoCapturer = new GumVideoCapturer(640, 480, 30);
111+
this.videoCapturer = new GumVideoCapturer(
112+
REQUESTED_VIDEO_WIDTH,
113+
REQUESTED_VIDEO_HEIGHT,
114+
REQUESTED_VIDEO_FRAMERATE
115+
);
107116
this.videoRenderer = new CanvasVideoRenderer();
108117

109118
this.callsByConversation = {};

ts/util/lint/exceptions.json

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14391,16 +14391,7 @@
1439114391
"rule": "React-useRef",
1439214392
"path": "ts/components/CallingLobby.js",
1439314393
"line": " const localVideoRef = react_1.default.useRef(null);",
14394-
"lineNumber": 15,
14395-
"reasonCategory": "usageTrusted",
14396-
"updated": "2020-10-26T19:12:24.410Z",
14397-
"reasonDetail": "Used to get the local video element for rendering."
14398-
},
14399-
{
14400-
"rule": "React-useRef",
14401-
"path": "ts/components/CallingLobby.tsx",
14402-
"line": " const localVideoRef = React.useRef(null);",
14403-
"lineNumber": 64,
14394+
"lineNumber": 24,
1440414395
"reasonCategory": "usageTrusted",
1440514396
"updated": "2020-10-26T19:12:24.410Z",
1440614397
"reasonDetail": "Used to get the local video element for rendering."

0 commit comments

Comments
 (0)