diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 25814d8db2..e07b3f412b 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -4309,7 +4309,7 @@ button.module-image__border-overlay:focus { inset-inline-start: 8px; // These are the normal widths; the size will change based on whether the window // is large, like the overall pip. - height: 54px; + height: 60px; width: 80px; border-radius: 12px; @@ -4330,14 +4330,18 @@ button.module-image__border-overlay:focus { &__full-size-local-preview { width: 100%; + height: 100%; + object-fit: contain; position: relative; video { width: 100%; + height: 100%; transform: rotateY(180deg); + object-fit: contain; } - &--presenting { + &--presenting video { transform: none; } } diff --git a/ts/calling/VideoSupport.preload.ts b/ts/calling/VideoSupport.preload.ts index ea1d9c94fe..4a9942ae98 100644 --- a/ts/calling/VideoSupport.preload.ts +++ b/ts/calling/VideoSupport.preload.ts @@ -9,6 +9,7 @@ import { videoPixelFormatToEnum } from '@signalapp/ringrtc'; import type { VideoFrameSender, VideoFrameSource } from '@signalapp/ringrtc'; import type { RefObject } from 'react'; import { createLogger } from '../logging/log.std.js'; +import { toLogFormat } from '../types/errors.std.js'; const log = createLogger('VideoSupport'); @@ -35,45 +36,83 @@ type GumTrackConstraintSet = { maxFrameRate: number; }; +export type SizeCallbackType = (options: { + width: number; + height: number; +}) => unknown; + +export type SetLocalPreviewType = { + localPreview: HTMLVideoElement | undefined; + sizeCallback: SizeCallbackType | undefined; +}; + export class GumVideoCapturer { - private defaultCaptureOptions: GumVideoCaptureOptions; private localPreview?: HTMLVideoElement; + private sizeCallback?: SizeCallbackType; private captureOptions?: GumVideoCaptureOptions; private sender?: VideoFrameSender; private mediaStream?: MediaStream; private spawnedSenderRunning = false; private preferredDeviceId?: string; - - constructor(defaultCaptureOptions: GumVideoCaptureOptions) { - this.defaultCaptureOptions = defaultCaptureOptions; - } + private reportVideoSizeCallback = this.reportVideoSize.bind(this); capturing(): boolean { return this.captureOptions !== undefined; } - setLocalPreview(localPreview: HTMLVideoElement | undefined): void { + setLocalPreview(options: SetLocalPreviewType): void { const oldLocalPreview = this.localPreview; - if (oldLocalPreview) { - oldLocalPreview.srcObject = null; + + if (oldLocalPreview !== options.localPreview) { + if (oldLocalPreview) { + oldLocalPreview.srcObject = null; + oldLocalPreview.removeEventListener( + 'resize', + this.reportVideoSizeCallback + ); + } + + this.localPreview = options.localPreview; + + if (this.localPreview) { + this.localPreview.addEventListener( + 'resize', + this.reportVideoSizeCallback + ); + } + this.updateLocalPreviewSourceObject(); } - this.localPreview = localPreview; - - this.updateLocalPreviewSourceObject(); + this.sizeCallback = options.sizeCallback; + this.reportVideoSize(); } - async enableCapture(options?: GumVideoCaptureOptions): Promise { - return this.startCapturing(options ?? this.defaultCaptureOptions); + reportVideoSize(): void { + if (!this.mediaStream || !this.sizeCallback) { + return; + } + + const settings = this.mediaStream.getVideoTracks()?.[0].getSettings(); + if (!settings?.width || !settings?.height) { + return; + } + + const size = { + width: settings.width, + height: settings.height, + }; + this.sizeCallback(size); + } + + async enableCapture(options: GumVideoCaptureOptions): Promise { + return this.startCapturing(options); } async enableCaptureAndSend( - sender?: VideoFrameSender, - options?: GumVideoCaptureOptions + sender: VideoFrameSender | undefined, + options: GumVideoCaptureOptions ): Promise { - const startCapturingPromise = this.startCapturing( - options ?? this.defaultCaptureOptions - ); + const startCapturingPromise = this.startCapturing(options); if (sender) { this.startSending(sender); } @@ -223,7 +262,7 @@ export class GumVideoCapturer { this.updateLocalPreviewSourceObject(); } catch (e) { - log.error(`startCapturing(): ${e}`); + log.error(`startCapturing(): ${toLogFormat(e)}`); // It's possible video was disabled, switched to screenshare, or // switched to a different camera while awaiting a response, in @@ -378,6 +417,7 @@ export const MAX_VIDEO_CAPTURE_BUFFER_SIZE = MAX_VIDEO_CAPTURE_AREA * 4; export class CanvasVideoRenderer { private canvas?: RefObject; + private sizeCallback?: SizeCallbackType; private buffer: Uint8Array; private imageData?: ImageData; private source?: VideoFrameSource; @@ -390,6 +430,16 @@ export class CanvasVideoRenderer { setCanvas(canvas: RefObject | undefined): void { this.canvas = canvas; } + setSizer(callback: SizeCallbackType | undefined): void { + this.sizeCallback = callback; + + if (this.imageData) { + this.sizeCallback?.({ + width: this.imageData.width, + height: this.imageData.height, + }); + } + } enable(source: VideoFrameSource): void { if (this.source === source) { @@ -502,10 +552,17 @@ export class CanvasVideoRenderer { canvas.height = height; canvas.setAttribute('style', style); - if (this.imageData?.width !== width || this.imageData?.height !== height) { + const sizeChanged = + this.imageData?.width !== width || this.imageData?.height !== height; + + if (!this.imageData || sizeChanged) { this.imageData = new ImageData(width, height); } this.imageData.data.set(this.buffer.subarray(0, width * height * 4)); context.putImageData(this.imageData, 0, 0); + + if (sizeChanged) { + this.sizeCallback?.({ width, height }); + } } } diff --git a/ts/components/CallManager.dom.tsx b/ts/components/CallManager.dom.tsx index cbff021644..ceda9fcfc3 100644 --- a/ts/components/CallManager.dom.tsx +++ b/ts/components/CallManager.dom.tsx @@ -59,6 +59,7 @@ import { } from '../types/NotificationProfile.std.js'; import type { NotificationProfileType } from '../types/NotificationProfile.std.js'; import { strictAssert } from '../util/assert.std.js'; +import type { SetLocalPreviewContainerType } from '../services/calling.preload.js'; const { noop } = lodash; @@ -137,7 +138,7 @@ export type PropsType = { setLocalAudio: SetLocalAudioType; setLocalVideo: SetLocalVideoType; setLocalAudioRemoteMuted: SetMutedByType; - setLocalPreviewContainer: (container: HTMLDivElement | null) => void; + setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => void; setOutgoingRing: (_: boolean) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; showShareCallLinkViaSignal: ( diff --git a/ts/components/CallScreen.dom.tsx b/ts/components/CallScreen.dom.tsx index d5389c6818..e277fa09da 100644 --- a/ts/components/CallScreen.dom.tsx +++ b/ts/components/CallScreen.dom.tsx @@ -106,6 +106,12 @@ import { beforeNavigateService, } from '../services/BeforeNavigate.std.js'; import { createLogger } from '../logging/log.std.js'; +import type { SetLocalPreviewContainerType } from '../services/calling.preload.js'; +import type { SizeCallbackType } from '../calling/VideoSupport.preload.js'; +import { + PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER, + PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER, +} from './CallingPip.dom.js'; const { isEqual, noop } = lodash; @@ -137,7 +143,7 @@ export type PropsType = { ) => void; setLocalAudio: SetLocalAudioType; setLocalVideo: SetLocalVideoType; - setLocalPreviewContainer: (container: HTMLDivElement | null) => void; + setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; stickyControls: boolean; switchToPresentationView: () => void; @@ -179,6 +185,11 @@ const REACTIONS_BURST_TRAILING_WINDOW = 2000; const REACTIONS_BURST_MAX_IN_SHORT_WINDOW = 3; const REACTIONS_BURST_SHORT_WINDOW = 4000; +const LOCAL_PREVIEW_HEIGHT_NORMAL = 80; +const LOCAL_PREVIEW_WIDTH_NORMAL = 106.67; +const LOCAL_PREVIEW_HEIGHT_LARGE = 234; +const LOCAL_PREVIEW_WIDTH_LARGE = 312; + function CallDuration({ joinedAt, }: { @@ -289,6 +300,20 @@ export function CallScreen({ hangUpActiveCall('button click'); }, [hangUpActiveCall]); + const localPreviewRef = React.useRef(null); + const lonelyCallPreviewRef = React.useRef(null); + + const [localPreviewHeight, setLocalPreviewHeight] = React.useState( + activeCall.selfViewExpanded + ? LOCAL_PREVIEW_HEIGHT_LARGE + : LOCAL_PREVIEW_HEIGHT_NORMAL + ); + const [localPreviewWidth, setLocalPreviewWidth] = React.useState( + activeCall.selfViewExpanded + ? LOCAL_PREVIEW_WIDTH_LARGE + : LOCAL_PREVIEW_WIDTH_NORMAL + ); + const reactButtonRef = React.useRef(null); const reactionPickerRef = React.useRef(null); const reactionPickerContainerRef = React.useRef(null); @@ -510,6 +535,71 @@ export function CallScreen({ [toggleSelfViewExpanded] ); + const handleSize = React.useCallback( + (size: Parameters[0]) => { + const ratio = size.width / size.height; + + const newLocalPreviewWidth = localPreviewHeight * ratio; + if ( + newLocalPreviewWidth !== localPreviewWidth && + ratio >= PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER && + ratio <= PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER + ) { + setLocalPreviewWidth(newLocalPreviewWidth); + } + }, + [localPreviewHeight, localPreviewWidth, setLocalPreviewWidth] + ); + + React.useLayoutEffect(() => { + if (isLonelyInCall && !lonelyCallPreviewRef.current) { + return; + } + if (!isLonelyInCall && !localPreviewRef.current) { + return; + } + + if (lonelyCallPreviewRef.current) { + setLocalPreviewContainer({ + container: lonelyCallPreviewRef.current, + sizeCallback: undefined, + }); + } + if (localPreviewRef.current) { + setLocalPreviewContainer({ + container: localPreviewRef.current, + sizeCallback: handleSize, + }); + } + }, [handleSize, isLonelyInCall, setLocalPreviewContainer]); + + const { selfViewExpanded } = activeCall; + const previousSelfViewExpanded = usePrevious( + selfViewExpanded, + selfViewExpanded + ); + React.useLayoutEffect(() => { + if (selfViewExpanded === previousSelfViewExpanded) { + return; + } + + const existingAspectRatio = localPreviewWidth / localPreviewHeight; + if (selfViewExpanded) { + setLocalPreviewHeight(LOCAL_PREVIEW_HEIGHT_LARGE); + setLocalPreviewWidth(LOCAL_PREVIEW_HEIGHT_LARGE * existingAspectRatio); + } else { + setLocalPreviewHeight(LOCAL_PREVIEW_HEIGHT_NORMAL); + setLocalPreviewWidth(LOCAL_PREVIEW_HEIGHT_NORMAL * existingAspectRatio); + } + }, [ + localPreviewHeight, + localPreviewWidth, + previousSelfViewExpanded, + selfViewExpanded, + setLocalPreviewHeight, + setLocalPreviewWidth, + ]); + if (isLonelyInCall) { lonelyInCallNode = (
) : ( @@ -542,7 +632,7 @@ export function CallScreen({ presentingSource && 'module-ongoing-call__local-preview__video--presenting' )} - ref={setLocalPreviewContainer} + ref={localPreviewRef} /> ) : ( + <> + +
+ +
+
+ + ) : (
diff --git a/ts/components/CallingLobby.dom.tsx b/ts/components/CallingLobby.dom.tsx index d9030fca0d..083f6bc0c9 100644 --- a/ts/components/CallingLobby.dom.tsx +++ b/ts/components/CallingLobby.dom.tsx @@ -28,6 +28,7 @@ import { CallingButtonToastsContainer } from './CallingToastManager.dom.js'; import { isGroupOrAdhocCallMode } from '../util/isGroupOrAdhocCall.std.js'; import { Button, ButtonVariant } from './Button.dom.js'; import { SpinnerV2 } from './SpinnerV2.dom.js'; +import type { SetLocalPreviewContainerType } from '../services/calling.preload.js'; export type PropsType = { availableCameras: Array; @@ -74,7 +75,7 @@ export type PropsType = { peekedParticipants: Array; setLocalAudio: SetLocalAudioType; setLocalVideo: SetLocalVideoType; - setLocalPreviewContainer: (container: HTMLDivElement | null) => void; + setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => void; setOutgoingRing: (_: boolean) => void; showParticipantsList: boolean; toggleParticipants: () => void; @@ -251,13 +252,23 @@ export function CallingLobby({ useWasInitiallyMutedToast(hasLocalAudio, i18n); + const onLocalPreviewContainerRef = React.useCallback( + (container: HTMLDivElement) => { + setLocalPreviewContainer({ + container, + sizeCallback: undefined, + }); + }, + [setLocalPreviewContainer] + ); + return (
{shouldShowLocalVideo ? (
) : ( { - container?.appendChild(localPreviewVideo); + setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => { + options.container?.appendChild(localPreviewVideo); }, setRendererCanvas: ({ element }: SetRendererCanvasType) => { element?.current?.getContext('2d')?.drawImage(videoScreenshot, 0, 0); diff --git a/ts/components/CallingPip.dom.tsx b/ts/components/CallingPip.dom.tsx index 7cbde611d7..23a9ae1509 100644 --- a/ts/components/CallingPip.dom.tsx +++ b/ts/components/CallingPip.dom.tsx @@ -3,7 +3,7 @@ import React from 'react'; import classNames from 'classnames'; -import lodash from 'lodash'; +import lodash, { clamp } from 'lodash'; import type { VideoFrameSource } from '@signalapp/ringrtc'; @@ -26,6 +26,10 @@ import type { CallingImageDataCache } from './CallManager.dom.js'; import type { ConversationType } from '../state/ducks/conversations.preload.js'; import { Avatar, AvatarSize } from './Avatar.dom.js'; import { AvatarColors } from '../types/Colors.std.js'; +import type { SetLocalPreviewContainerType } from '../services/calling.preload.js'; +import { usePrevious } from '../hooks/usePrevious.std.js'; +import type { SizeCallbackType } from '../calling/VideoSupport.preload.js'; +import { MAX_FRAME_HEIGHT } from '../calling/constants.std.js'; const { minBy, debounce, noop } = lodash; @@ -86,7 +90,7 @@ export type PropsType = { _: Array, speakerHeight: number ) => void; - setLocalPreviewContainer: (container: HTMLDivElement | null) => void; + setLocalPreviewContainer: (options: SetLocalPreviewContainerType) => void; setRendererCanvas: (_: SetRendererCanvasType) => void; switchToPresentationView: () => void; switchFromPresentationView: () => void; @@ -102,13 +106,21 @@ const LARGE_THRESHOLD = 1200; export const PIP_WIDTH_NORMAL = 160; const PIP_WIDTH_LARGE = 224; const PIP_TOP_MARGIN = 78; -const PIP_PADDING = 8; +const PIP_PADDING = 15; // Receiving portrait video will cause the PIP to update to match that video size, but // we need limits export const PIP_MINIMUM_HEIGHT_MULTIPLIER = 1.2; export const PIP_MAXIMUM_HEIGHT_MULTIPLIER = 2; +const LOCAL_VIDEO_LARGE_WIDTH = 120; +const LOCAL_VIDEO_LARGE_HEIGHT = 90; +const LOCAL_VIDEO_NORMAL_WIDTH = 80; +const LOCAL_VIDEO_NORMAL_HEIGHT = 60; + +export const PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER = 0.5; +export const PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER = 2; + export function CallingPip({ activeCall, getGroupCallVideoFrameSource, @@ -143,6 +155,12 @@ export function CallingPip({ const [width, setWidth] = React.useState( isWindowLarge ? PIP_WIDTH_LARGE : PIP_WIDTH_NORMAL ); + const [localVideoWidth, setLocalVideoWidth] = React.useState( + isWindowLarge ? LOCAL_VIDEO_LARGE_WIDTH : LOCAL_VIDEO_NORMAL_WIDTH + ); + const [localVideoHeight, setLocalVideoHeight] = React.useState( + isWindowLarge ? LOCAL_VIDEO_LARGE_HEIGHT : LOCAL_VIDEO_NORMAL_HEIGHT + ); useActivateSpeakerViewOnPresenting({ remoteParticipants: activeCall.remoteParticipants, @@ -264,16 +282,46 @@ export function CallingPip({ }; }, []); + const previousIsWindowLarge = usePrevious(isWindowLarge, isWindowLarge); // This only runs when isWindowLarge changes, so we aggressively change height + width React.useEffect(() => { - if (isWindowLarge) { - setHeight(PIP_STARTING_HEIGHT_LARGE); - setWidth(PIP_WIDTH_LARGE); - } else { - setHeight(PIP_STARTING_HEIGHT_NORMAL); - setWidth(PIP_WIDTH_NORMAL); + if (previousIsWindowLarge === isWindowLarge) { + return; } - }, [isWindowLarge, setHeight, setWidth]); + const existingPortraitAspectRatio = height / width; + + if (isWindowLarge) { + setWidth(PIP_WIDTH_LARGE); + setHeight(PIP_WIDTH_LARGE * existingPortraitAspectRatio); + } else { + setWidth(PIP_WIDTH_NORMAL); + setHeight(PIP_WIDTH_NORMAL * existingPortraitAspectRatio); + } + + const existingLocalVideoPortraitAspectRatio = localVideoHeight; + if (isWindowLarge) { + setLocalVideoWidth(LOCAL_VIDEO_LARGE_WIDTH); + setLocalVideoHeight( + LOCAL_VIDEO_LARGE_WIDTH * existingLocalVideoPortraitAspectRatio + ); + } else { + setLocalVideoWidth(LOCAL_VIDEO_NORMAL_WIDTH); + setLocalVideoHeight( + LOCAL_VIDEO_NORMAL_WIDTH * existingLocalVideoPortraitAspectRatio + ); + } + }, [ + height, + isWindowLarge, + localVideoHeight, + localVideoWidth, + previousIsWindowLarge, + setHeight, + setLocalVideoHeight, + setLocalVideoWidth, + setWidth, + width, + ]); const [translateX, translateY] = React.useMemo<[number, number]>(() => { const topMin = PIP_TOP_MARGIN; @@ -387,6 +435,72 @@ export function CallingPip({ ? AvatarSize.NINETY_SIX : AvatarSize.SIXTY_FOUR; + const lonelyCallPreviewRef = React.useRef(null); + const localPreviewRef = React.useRef(null); + + const handleSize = React.useCallback( + (size: Parameters[0]) => { + const portraitRatio = size.height / size.width; + + if (isLonelyInCall) { + const newHeight = clamp( + Math.floor(width * portraitRatio), + 1, + MAX_FRAME_HEIGHT + ); + + if ( + newHeight !== height && + portraitRatio >= PIP_MINIMUM_HEIGHT_MULTIPLIER && + portraitRatio <= PIP_MAXIMUM_HEIGHT_MULTIPLIER + ) { + setHeight(newHeight); + } + + return; + } + + const newLocalVideoHeight = localVideoWidth * portraitRatio; + if ( + newLocalVideoHeight !== localVideoHeight && + portraitRatio >= PIP_MINIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER && + portraitRatio <= PIP_MAXIMUM_LOCAL_VIDEO_HEIGHT_MULTIPLIER + ) { + setLocalVideoHeight(newLocalVideoHeight); + } + }, + [ + height, + isLonelyInCall, + setHeight, + width, + localVideoWidth, + localVideoHeight, + ] + ); + + React.useLayoutEffect(() => { + if (isLonelyInCall && !lonelyCallPreviewRef.current) { + return; + } + if (!isLonelyInCall && !localPreviewRef.current) { + return; + } + + if (lonelyCallPreviewRef.current) { + setLocalPreviewContainer({ + container: lonelyCallPreviewRef.current, + sizeCallback: handleSize, + }); + } + if (localPreviewRef.current) { + setLocalPreviewContainer({ + container: localPreviewRef.current, + sizeCallback: handleSize, + }); + } + }, [handleSize, isLonelyInCall, setLocalPreviewContainer]); + if (isLonelyInCall) { remoteVideoNode = (
@@ -400,7 +514,7 @@ export function CallingPip({ ? 'module-calling-pip__full-size-local-preview--presenting' : undefined )} - ref={setLocalPreviewContainer} + ref={lonelyCallPreviewRef} /> ) : ( @@ -442,8 +556,6 @@ export function CallingPip({ /> ); } - const localVideoWidth = isWindowLarge ? 120 : 80; - const localVideoHeight = isWindowLarge ? 80 : 54; return ( // eslint-disable-next-line jsx-a11y/no-static-element-interactions @@ -502,7 +614,7 @@ export function CallingPip({ width: `${localVideoWidth}px`, }} className={localVideoClassName} - ref={setLocalPreviewContainer} + ref={localPreviewRef} /> ) : null} diff --git a/ts/components/CallingPipRemoteVideo.dom.tsx b/ts/components/CallingPipRemoteVideo.dom.tsx index 9ea6584285..9cf6058c21 100644 --- a/ts/components/CallingPipRemoteVideo.dom.tsx +++ b/ts/components/CallingPipRemoteVideo.dom.tsx @@ -130,52 +130,51 @@ export function CallingPipRemoteVideo({ }, [activeCall]); useEffect(() => { - if (isGroupOrAdhocActiveCall(activeCall)) { - if (!activeGroupCallSpeaker || !activeGroupCallSpeaker.hasRemoteVideo) { - return; - } - const { videoAspectRatio } = activeGroupCallSpeaker; - if (!isNumber(videoAspectRatio)) { - return; - } + if (!isGroupOrAdhocActiveCall(activeCall)) { + return; + } - const ratio = 1 / videoAspectRatio; - const newHeight = clamp(Math.floor(width * ratio), 1, MAX_FRAME_HEIGHT); + if (!activeGroupCallSpeaker || !activeGroupCallSpeaker.hasRemoteVideo) { + return; + } + const { videoAspectRatio } = activeGroupCallSpeaker; + if (!isNumber(videoAspectRatio)) { + return; + } - // Update only for portrait video that fits, otherwise leave things as they are - if ( - newHeight !== height && - ratio >= PIP_MINIMUM_HEIGHT_MULTIPLIER && - ratio <= PIP_MAXIMUM_HEIGHT_MULTIPLIER - ) { - updateHeight(newHeight); - } + const portraitRatio = 1 / videoAspectRatio; + const newHeight = clamp( + Math.floor(width * portraitRatio), + 1, + MAX_FRAME_HEIGHT + ); - if (isPageVisible) { - const participants = activeCall.remoteParticipants.map(participant => { - if (participant === activeGroupCallSpeaker) { - return { - demuxId: participant.demuxId, - width, - height: newHeight, - }; - } - return nonRenderedRemoteParticipant(participant); - }); - setGroupCallVideoRequest(participants, newHeight); - } else { - setGroupCallVideoRequest( - activeCall.remoteParticipants.map(nonRenderedRemoteParticipant), - 0 - ); - } + // Update only for portrait video that fits, otherwise leave things as they are + if ( + newHeight !== height && + portraitRatio >= PIP_MINIMUM_HEIGHT_MULTIPLIER && + portraitRatio <= PIP_MAXIMUM_HEIGHT_MULTIPLIER + ) { + updateHeight(newHeight); + } + + if (isPageVisible) { + const participants = activeCall.remoteParticipants.map(participant => { + if (participant === activeGroupCallSpeaker) { + return { + demuxId: participant.demuxId, + width, + height: newHeight, + }; + } + return nonRenderedRemoteParticipant(participant); + }); + setGroupCallVideoRequest(participants, newHeight); } else { - // 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 + setGroupCallVideoRequest( + activeCall.remoteParticipants.map(nonRenderedRemoteParticipant), + 0 + ); } }, [ activeCall, @@ -187,6 +186,27 @@ export function CallingPipRemoteVideo({ width, ]); + const handleDirectSize = React.useCallback( + (newSize: { width: number; height: number }) => { + const portraitRatio = newSize.height / newSize.width; + const newHeight = clamp( + Math.floor(width * portraitRatio), + 1, + MAX_FRAME_HEIGHT + ); + + // Update only for portrait video that fits, otherwise leave things as they are + if ( + newHeight !== height && + portraitRatio >= PIP_MINIMUM_HEIGHT_MULTIPLIER && + portraitRatio <= PIP_MAXIMUM_HEIGHT_MULTIPLIER + ) { + updateHeight(newHeight); + } + }, + [height, updateHeight, width] + ); + const avatarSize = width > PIP_WIDTH_NORMAL ? AvatarSize.NINETY_SIX : AvatarSize.SIXTY_FOUR; @@ -219,6 +239,7 @@ export function CallingPipRemoteVideo({ void; }; @@ -24,15 +26,16 @@ export function DirectCallRemoteParticipant({ i18n, isReconnecting, setRendererCanvas, + handleSize, }: PropsType): React.JSX.Element { const remoteVideoRef = useRef(null); useEffect(() => { - setRendererCanvas({ element: remoteVideoRef }); + setRendererCanvas({ element: remoteVideoRef, sizeCallback: handleSize }); return () => { - setRendererCanvas({ element: undefined }); + setRendererCanvas({ element: undefined, sizeCallback: undefined }); }; - }, [setRendererCanvas]); + }, [handleSize, setRendererCanvas]); return hasRemoteVideo ? ( ; constructor() { - this.#videoCapturer = new GumVideoCapturer(DIRECT_CALL_OPTIONS); + this.#videoCapturer = new GumVideoCapturer(); this.videoRenderer = new CanvasVideoRenderer(); this.#callsLookup = {}; @@ -1217,18 +1225,21 @@ export class CallingClass { ); } - public setLocalPreviewContainer(container: HTMLDivElement | null): void { + public setLocalPreviewContainer(options: SetLocalPreviewContainerType): void { // Reuse HTMLVideoElement between different containers so that the preview // of the last frame stays valid even if there are no new frames on the // underlying MediaStream. if (this.#localPreview == null) { this.#localPreview = document.createElement('video'); this.#localPreview.autoplay = true; - this.#videoCapturer.setLocalPreview(this.#localPreview); } + this.#videoCapturer.setLocalPreview({ + localPreview: this.#localPreview, + sizeCallback: options.sizeCallback, + }); this.#localPreviewContainer?.removeChild(this.#localPreview); - this.#localPreviewContainer = container; + this.#localPreviewContainer = options.container; this.#localPreviewContainer?.appendChild(this.#localPreview); } @@ -2966,13 +2977,15 @@ export class CallingClass { ); await this.#videoCapturer.enableCapture( - mode === CallMode.Direct ? undefined : GROUP_CALL_OPTIONS + mode === CallMode.Direct ? DIRECT_CALL_OPTIONS : GROUP_CALL_OPTIONS ); } async enableCaptureAndSend( call: GroupCall | Call, - options = call instanceof GroupCall ? GROUP_CALL_OPTIONS : undefined, + options = call instanceof GroupCall + ? GROUP_CALL_OPTIONS + : DIRECT_CALL_OPTIONS, logId = 'enableCaptureAndSend' ): Promise { try { diff --git a/ts/state/ducks/calling.preload.ts b/ts/state/ducks/calling.preload.ts index 93331f99d7..93cc5cabef 100644 --- a/ts/state/ducks/calling.preload.ts +++ b/ts/state/ducks/calling.preload.ts @@ -130,6 +130,7 @@ import { submitCallQualitySurvey as submitCallQualitySurveyToServer, } from '../../textsecure/WebAPI.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import type { SizeCallbackType } from '../../calling/VideoSupport.preload.js'; const { omit } = lodash; @@ -499,6 +500,7 @@ type StartCallLinkLobbyPayloadType = { // eslint-disable-next-line local-rules/type-alias-readonlydeep export type SetRendererCanvasType = { element: React.RefObject | undefined; + sizeCallback: SizeCallbackType | undefined; }; // Helpers @@ -1992,6 +1994,7 @@ function setRendererCanvas( ): ThunkAction { return () => { calling.videoRenderer.setCanvas(payload.element); + calling.videoRenderer.setSizer(payload.sizeCallback); }; } diff --git a/ts/state/smart/CallManager.preload.tsx b/ts/state/smart/CallManager.preload.tsx index c35f0b13ad..15a7569c32 100644 --- a/ts/state/smart/CallManager.preload.tsx +++ b/ts/state/smart/CallManager.preload.tsx @@ -12,6 +12,7 @@ import { CallManager } from '../../components/CallManager.dom.js'; import { isConversationTooBigToRing as getIsConversationTooBigToRing } from '../../conversations/isConversationTooBigToRing.dom.js'; import { createLogger } from '../../logging/log.std.js'; import { calling as callingService } from '../../services/calling.preload.js'; +import type { SetLocalPreviewContainerType } from '../../services/calling.preload.js'; import { bounceAppIconStart, bounceAppIconStop, @@ -69,8 +70,8 @@ const getGroupCallVideoFrameSource = const notifyForCall = callingService.notifyForCall.bind(callingService); -function setLocalPreviewContainer(container: HTMLDivElement | null): void { - callingService.setLocalPreviewContainer(container); +function setLocalPreviewContainer(options: SetLocalPreviewContainerType): void { + callingService.setLocalPreviewContainer(options); } const playRingtone = callingTones.playRingtone.bind(callingTones); diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index efe54e67f5..984e0073b3 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -999,6 +999,20 @@ "reasonCategory": "usageTrusted", "updated": "2024-01-16T22:59:06.336Z" }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.dom.tsx", + "line": " const localPreviewRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-30T20:46:48.849Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallScreen.dom.tsx", + "line": " const lonelyCallPreviewRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-30T20:46:48.849Z" + }, { "rule": "React-useRef", "path": "ts/components/CallingPendingParticipants.dom.tsx", @@ -1022,6 +1036,20 @@ "reasonCategory": "usageTrusted", "updated": "2021-07-30T16:57:33.618Z" }, + { + "rule": "React-useRef", + "path": "ts/components/CallingPip.dom.tsx", + "line": " const lonelyCallPreviewRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-30T20:46:48.849Z" + }, + { + "rule": "React-useRef", + "path": "ts/components/CallingPip.dom.tsx", + "line": " const localPreviewRef = React.useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-30T20:46:48.849Z" + }, { "rule": "React-useRef", "path": "ts/components/CallingToast.dom.tsx", @@ -2136,6 +2164,13 @@ "updated": "2025-05-19T22:29:15.758Z", "reasonDetail": "Holding on to a promise" }, + { + "rule": "React-useRef", + "path": "ts/hooks/useDocumentKeyDown.dom.ts", + "line": " const listenerRef = useRef(listener);", + "reasonCategory": "usageTrusted", + "updated": "2025-12-09T15:37:49.757Z" + }, { "rule": "React-useRef", "path": "ts/hooks/useIntersectionObserver.std.ts", @@ -2335,12 +2370,5 @@ "line": " message.innerHTML = window.SignalContext.i18n('icu:optimizingApplication');", "reasonCategory": "usageTrusted", "updated": "2021-09-17T21:02:59.414Z" - }, - { - "rule": "React-useRef", - "path": "ts/hooks/useDocumentKeyDown.dom.ts", - "line": " const listenerRef = useRef(listener);", - "reasonCategory": "usageTrusted", - "updated": "2025-12-09T15:37:49.757Z" } ]