diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index e62aa78819..0f0a9fd44c 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -1559,6 +1559,30 @@ Signal Desktop makes use of the following open source projects. OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## @types/dom-mediacapture-transform + + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE + ## @types/fabric MIT License @@ -13576,7 +13600,7 @@ For more information on this, and how to apply and follow the GNU AGPL, see ``` -## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.50.5, protobuf 2.50.5, ringrtc 2.50.5, regex-aot 0.1.0, partial-default-derive 0.1.0 +## libsignal-account-keys 0.1.0, libsignal-core 0.1.0, mrp 2.51.0, protobuf 2.51.0, ringrtc 2.51.0, regex-aot 0.1.0, partial-default-derive 0.1.0 ``` GNU AFFERO GENERAL PUBLIC LICENSE diff --git a/package.json b/package.json index cb3e98057a..2b2ab9b45d 100644 --- a/package.json +++ b/package.json @@ -121,9 +121,10 @@ "@react-types/shared": "3.27.0", "@signalapp/libsignal-client": "0.70.0", "@signalapp/quill-cjs": "2.1.2", - "@signalapp/ringrtc": "2.50.5", + "@signalapp/ringrtc": "2.51.0", "@signalapp/sqlcipher": "2.0.0", "@tanstack/react-virtual": "3.11.2", + "@types/dom-mediacapture-transform": "0.1.11", "@types/fabric": "4.5.3", "backbone": "1.6.0", "blob-util": "2.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d932c2d7c4..74eebe6949 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,14 +138,17 @@ importers: specifier: 2.1.2 version: 2.1.2 '@signalapp/ringrtc': - specifier: 2.50.5 - version: 2.50.5 + specifier: 2.51.0 + version: 2.51.0 '@signalapp/sqlcipher': specifier: 2.0.0 version: 2.0.0 '@tanstack/react-virtual': specifier: 3.11.2 version: 3.11.2(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@types/dom-mediacapture-transform': + specifier: 0.1.11 + version: 0.1.11 '@types/fabric': specifier: 4.5.3 version: 4.5.3(patch_hash=e5f339ecf72fbab1c91505e7713e127a7184bfe8164aa3a9afe9bf45a0ad6b89) @@ -2547,8 +2550,8 @@ packages: resolution: {integrity: sha512-y2sgqdivlrG41J4Zvt/82xtH/PZjDlgItqlD2g/Cv3ZbjlR6cGhTNXbfNygCJB8nXj+C7I28pjt1Zm3k0pv2mg==} engines: {npm: '>=8.2.3'} - '@signalapp/ringrtc@2.50.5': - resolution: {integrity: sha512-4+2IfQv/wx9RMztM/tnZsvk6LjElZq7OgHITlszwJib2Gzh2Q31kyZ7vgLnsfFxfbzVQrWI9cTEc0u/hqJiDmg==} + '@signalapp/ringrtc@2.51.0': + resolution: {integrity: sha512-p0S7JLReO9NjfxB3Er3V6eydNB4IUfrIIimtlD7E9CerUWtejxvPNqEPxfKTCL/vVde/pqsIn0Qw9LjysA84xA==} '@signalapp/sqlcipher@2.0.0': resolution: {integrity: sha512-1VglhOpAsAHvTFoqB1gkwbnWwU0h37flLhRHcKKYyfEbRVc+3TDRBJ54Fm8WEELnmZPju1HVGM4tnyKgq7eI+A==} @@ -2956,6 +2959,12 @@ packages: '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + '@types/dom-mediacapture-transform@0.1.11': + resolution: {integrity: sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ==} + + '@types/dom-webcodecs@0.1.14': + resolution: {integrity: sha512-ba9aF0qARLLQpLihONIRbj8VvAdUxO+5jIxlscVcDAQTcJmq5qVr781+ino5qbQUJUmO21cLP2eLeXYWzao5Vg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -12148,7 +12157,7 @@ snapshots: lodash: 4.17.21 quill-delta: 5.1.0 - '@signalapp/ringrtc@2.50.5': + '@signalapp/ringrtc@2.51.0': dependencies: https-proxy-agent: 7.0.6 tar: 6.2.1 @@ -12700,6 +12709,12 @@ snapshots: '@types/doctrine@0.0.9': {} + '@types/dom-mediacapture-transform@0.1.11': + dependencies: + '@types/dom-webcodecs': 0.1.14 + + '@types/dom-webcodecs@0.1.14': {} + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 diff --git a/ts/calling/VideoSupport.ts b/ts/calling/VideoSupport.ts new file mode 100644 index 0000000000..dbd22bcebd --- /dev/null +++ b/ts/calling/VideoSupport.ts @@ -0,0 +1,498 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +/* eslint-disable max-classes-per-file */ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-await-in-loop */ + +import { videoPixelFormatToEnum } from '@signalapp/ringrtc'; +import type { VideoFrameSender, VideoFrameSource } from '@signalapp/ringrtc'; +import type { RefObject } from 'react'; +import * as log from '../logging/log'; + +export class GumVideoCaptureOptions { + maxWidth = 640; + maxHeight = 480; + maxFramerate = 30; + preferredDeviceId?: string; + screenShareSourceId?: string; + mediaStream?: MediaStream; + onEnded?: () => void; +} + +interface GumConstraints extends MediaStreamConstraints { + video?: boolean | GumTrackConstraints; +} + +interface GumTrackConstraints extends MediaTrackConstraints { + mandatory?: GumTrackConstraintSet; +} + +type GumTrackConstraintSet = { + chromeMediaSource: string; + chromeMediaSourceId?: string; + maxWidth: number; + maxHeight: number; + minFrameRate: number; + maxFrameRate: number; +}; + +export class GumVideoCapturer { + private defaultCaptureOptions: GumVideoCaptureOptions; + private localPreview?: RefObject; + private captureOptions?: GumVideoCaptureOptions; + private sender?: VideoFrameSender; + private mediaStream?: MediaStream; + private spawnedSenderRunning = false; + private preferredDeviceId?: string; + private updateLocalPreviewIntervalId?: any; + + constructor(defaultCaptureOptions: GumVideoCaptureOptions) { + this.defaultCaptureOptions = defaultCaptureOptions; + } + + capturing(): boolean { + return this.captureOptions !== undefined; + } + + setLocalPreview(localPreview: RefObject | undefined): void { + const oldLocalPreview = this.localPreview?.current; + if (oldLocalPreview) { + oldLocalPreview.srcObject = null; + } + + this.localPreview = localPreview; + + this.updateLocalPreviewSourceObject(); + + // This is a dumb hack around the fact that sometimes the + // this.localPreview.current is updated without a call + // to setLocalPreview, in which case the local preview + // won't be rendered. + if (this.updateLocalPreviewIntervalId !== undefined) { + clearInterval(this.updateLocalPreviewIntervalId); + } + this.updateLocalPreviewIntervalId = setInterval( + this.updateLocalPreviewSourceObject.bind(this), + 1000 + ); + } + + async enableCapture(options?: GumVideoCaptureOptions): Promise { + return this.startCapturing(options ?? this.defaultCaptureOptions); + } + + async enableCaptureAndSend( + sender?: VideoFrameSender, + options?: GumVideoCaptureOptions + ): Promise { + const startCapturingPromise = this.startCapturing( + options ?? this.defaultCaptureOptions + ); + if (sender) { + this.startSending(sender); + } + // Bubble up the error. + return startCapturingPromise; + } + + disable(): void { + this.stopCapturing(); + this.stopSending(); + + if (this.updateLocalPreviewIntervalId !== undefined) { + clearInterval(this.updateLocalPreviewIntervalId); + } + this.updateLocalPreviewIntervalId = undefined; + } + + async setPreferredDevice(deviceId: string): Promise { + this.preferredDeviceId = deviceId; + + if (this.captureOptions) { + const { captureOptions, sender } = this; + + this.disable(); + // Bubble up the error if starting video failed. + return this.enableCaptureAndSend(sender, captureOptions); + } + } + + async enumerateDevices(): Promise> { + const devices = await window.navigator.mediaDevices.enumerateDevices(); + const cameras = devices.filter(d => d.kind === 'videoinput'); + return cameras; + } + + private async getUserMedia( + options: GumVideoCaptureOptions + ): Promise { + // Return provided media stream + if (options.mediaStream) { + return options.mediaStream; + } + + const constraints: GumConstraints = { + audio: false, + video: { + deviceId: options.preferredDeviceId ?? this.preferredDeviceId, + width: { + max: options.maxWidth, + ideal: options.maxWidth, + }, + height: { + max: options.maxHeight, + ideal: options.maxHeight, + }, + frameRate: { + max: options.maxFramerate, + ideal: options.maxFramerate, + }, + }, + }; + if (options.screenShareSourceId !== undefined) { + constraints.video = { + mandatory: { + chromeMediaSource: 'desktop', + chromeMediaSourceId: options.screenShareSourceId, + maxWidth: options.maxWidth, + maxHeight: options.maxHeight, + minFrameRate: 1, + maxFrameRate: options.maxFramerate, + }, + }; + } + return window.navigator.mediaDevices.getUserMedia(constraints); + } + + private async startCapturing(options: GumVideoCaptureOptions): Promise { + if (this.capturing()) { + log.warn('startCapturing(): already capturing'); + return; + } + log.info( + `startCapturing(): ${options.maxWidth}x${options.maxHeight}@${options.maxFramerate}` + ); + this.captureOptions = options; + try { + // If we start/stop/start, we may have concurrent calls to getUserMedia, + // which is what we want if we're switching from camera to screenshare. + // But we need to make sure we deal with the fact that things might be + // different after the await here. + const mediaStream = await this.getUserMedia(options); + // It's possible video was disabled, switched to screenshare, or + // switched to a different camera while awaiting a response, in + // which case we need to disable the camera we just accessed. + if (this.captureOptions !== options) { + log.warn('startCapturing(): different state after getUserMedia()'); + for (const track of mediaStream.getVideoTracks()) { + // Make the light turn off faster + track.stop(); + } + return; + } + + if ( + this.mediaStream !== undefined && + this.mediaStream.getVideoTracks().length > 0 + ) { + // We have a stream and track for the requested camera already. Stop + // the duplicate track that we just started. + log.warn('startCapturing(): dropping duplicate call to startCapturing'); + for (const track of mediaStream.getVideoTracks()) { + track.stop(); + } + return; + } + + this.mediaStream = mediaStream; + if ( + !this.spawnedSenderRunning && + this.mediaStream !== undefined && + this.sender !== undefined + ) { + this.spawnSender(this.mediaStream, this.sender); + } + + this.updateLocalPreviewSourceObject(); + } catch (e) { + log.error(`startCapturing(): ${e}`); + + // It's possible video was disabled, switched to screenshare, or + // switched to a different camera while awaiting a response, in + // which case we should reset the captureOptions if we set them. + if (this.captureOptions === options) { + // We couldn't open the camera. Oh well. + this.captureOptions = undefined; + } + // Re-raise so that callers can surface this condition to the user. + throw e; + } + } + + private stopCapturing(): void { + if (!this.capturing()) { + log.warn('stopCapturing(): not capturing'); + return; + } + log.info('stopCapturing()'); + this.captureOptions = undefined; + if (this.mediaStream) { + for (const track of this.mediaStream.getVideoTracks()) { + // Make the light turn off faster + track.stop(); + } + this.mediaStream = undefined; + } + + this.updateLocalPreviewSourceObject(); + } + + private startSending(sender: VideoFrameSender): void { + if (this.sender === sender) { + return; + } + if (this.sender) { + // If we're replacing an existing sender, make sure we stop the + // current setInterval loop before starting another one. + this.stopSending(); + } + this.sender = sender; + + if (!this.spawnedSenderRunning && this.mediaStream !== undefined) { + this.spawnSender(this.mediaStream, this.sender); + } + } + + private spawnSender(mediaStream: MediaStream, sender: VideoFrameSender) { + const track = mediaStream.getVideoTracks()[0]; + if (track === undefined || this.spawnedSenderRunning) { + return; + } + + const { onEnded } = this.captureOptions || {}; + + if (track.readyState === 'ended') { + this.stopCapturing(); + log.error('spawnSender(): Video track ended before spawning sender'); + return; + } + + const reader = new MediaStreamTrackProcessor({ + track, + }).readable.getReader(); + const buffer = Buffer.alloc(MAX_VIDEO_CAPTURE_BUFFER_SIZE); + this.spawnedSenderRunning = true; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + (async () => { + try { + while (mediaStream === this.mediaStream) { + const { done, value: frame } = await reader.read(); + if (done) { + break; + } + if (!frame) { + continue; + } + try { + const format = videoPixelFormatToEnum(frame.format ?? 'I420'); + if (format === undefined) { + log.warn(`Unsupported video frame format: ${frame.format}`); + break; + } + + const { width, height } = frame.visibleRect || {}; + if (!width || !height) { + continue; + } + + await frame.copyTo(buffer); + if (sender !== this.sender) { + break; + } + + sender.sendVideoFrame(width, height, format, buffer); + } catch (e) { + log.error(`sendVideoFrame(): ${e}`); + } finally { + // This must be called for more frames to come. + frame.close(); + } + } + } catch (e) { + log.error(`spawnSender(): ${e}`); + } finally { + reader.releaseLock(); + onEnded?.(); + } + this.spawnedSenderRunning = false; + })(); + } + + private stopSending(): void { + // The spawned sender should stop + this.sender = undefined; + } + + private updateLocalPreviewSourceObject(): void { + if (!this.localPreview) { + return; + } + const localPreview = this.localPreview.current; + if (!localPreview) { + return; + } + + const { mediaStream = null } = this; + + if (localPreview.srcObject === mediaStream) { + return; + } + + if (mediaStream && this.captureOptions) { + localPreview.srcObject = mediaStream; + if (localPreview.width === 0) { + localPreview.width = this.captureOptions.maxWidth; + } + if (localPreview.height === 0) { + localPreview.height = this.captureOptions.maxHeight; + } + } else { + localPreview.srcObject = null; + } + } +} + +export const MAX_VIDEO_CAPTURE_WIDTH = 2880; +export const MAX_VIDEO_CAPTURE_HEIGHT = 1800; +export const MAX_VIDEO_CAPTURE_AREA = + MAX_VIDEO_CAPTURE_WIDTH * MAX_VIDEO_CAPTURE_HEIGHT; +export const MAX_VIDEO_CAPTURE_BUFFER_SIZE = MAX_VIDEO_CAPTURE_AREA * 4; + +export class CanvasVideoRenderer { + private canvas?: RefObject; + private buffer: Buffer; + private imageData?: ImageData; + private source?: VideoFrameSource; + private rafId?: any; + + constructor() { + this.buffer = Buffer.alloc(MAX_VIDEO_CAPTURE_BUFFER_SIZE); + } + + setCanvas(canvas: RefObject | undefined): void { + this.canvas = canvas; + } + + enable(source: VideoFrameSource): void { + if (this.source === source) { + return; + } + if (this.source) { + // If we're replacing an existing source, make sure we stop the + // current rAF loop before starting another one. + if (this.rafId) { + window.cancelAnimationFrame(this.rafId); + } + } + this.source = source; + this.requestAnimationFrameCallback(); + } + + disable(): void { + this.renderBlack(); + this.source = undefined; + if (this.rafId) { + window.cancelAnimationFrame(this.rafId); + } + } + + private requestAnimationFrameCallback() { + this.renderVideoFrame(); + this.rafId = window.requestAnimationFrame( + this.requestAnimationFrameCallback.bind(this) + ); + } + + private renderBlack() { + if (!this.canvas) { + return; + } + const canvas = this.canvas.current; + if (!canvas) { + return; + } + const context = canvas.getContext('2d'); + if (!context) { + return; + } + context.fillStyle = 'black'; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + private renderVideoFrame() { + if (!this.source || !this.canvas) { + return; + } + const canvas = this.canvas.current; + if (!canvas) { + return; + } + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + const frame = this.source.receiveVideoFrame( + this.buffer, + MAX_VIDEO_CAPTURE_WIDTH, + MAX_VIDEO_CAPTURE_HEIGHT + ); + if (!frame) { + return; + } + const [width, height] = frame; + + if ( + canvas.clientWidth <= 0 || + width <= 0 || + canvas.clientHeight <= 0 || + height <= 0 + ) { + return; + } + + const frameAspectRatio = width / height; + const canvasAspectRatio = canvas.clientWidth / canvas.clientHeight; + + let dx = 0; + let dy = 0; + if (frameAspectRatio > canvasAspectRatio) { + // Frame wider than view: We need bars at the top and bottom + canvas.width = width; + canvas.height = width / canvasAspectRatio; + dy = (canvas.height - height) / 2; + } else if (frameAspectRatio < canvasAspectRatio) { + // Frame narrower than view: We need pillars on the sides + canvas.width = height * canvasAspectRatio; + canvas.height = height; + dx = (canvas.width - width) / 2; + } else { + // Will stretch perfectly with no bars + canvas.width = width; + canvas.height = height; + } + + if (dx > 0 || dy > 0) { + context.fillStyle = 'black'; + context.fillRect(0, 0, canvas.width, canvas.height); + } + + if (this.imageData?.width !== width || this.imageData?.height !== height) { + this.imageData = new ImageData(width, height); + } + this.imageData.data.set(this.buffer.subarray(0, width * height * 4)); + context.putImageData(this.imageData, dx, dy); + } +} diff --git a/ts/services/calling.ts b/ts/services/calling.ts index 8abe97cebc..d5b5e7ce8a 100644 --- a/ts/services/calling.ts +++ b/ts/services/calling.ts @@ -21,14 +21,12 @@ import { CallLinkRootKey, CallLogLevel, CallState, - CanvasVideoRenderer, ConnectionState, DataMode, JoinState, HttpMethod, GroupCall, GroupMemberInfo, - GumVideoCapturer, HangupMessage, HangupType, IceCandidateMessage, @@ -41,7 +39,6 @@ import { SpeechEvent, } from '@signalapp/ringrtc'; import { uniqBy, noop, compact } from 'lodash'; - import Long from 'long'; import type { CallLinkAuthCredentialPresentation } from '@signalapp/libsignal-client/zkgroup'; import { @@ -51,7 +48,8 @@ import { GenericServerPublicParams, } from '@signalapp/libsignal-client/zkgroup'; import { Aci } from '@signalapp/libsignal-client'; -import type { GumVideoCaptureOptions } from '@signalapp/ringrtc/dist/ringrtc/VideoSupport'; +import { CanvasVideoRenderer, GumVideoCapturer } from '../calling/VideoSupport'; +import type { GumVideoCaptureOptions } from '../calling/VideoSupport'; import type { ActionsType as CallingReduxActionsType, GroupCallParticipantInfoType, @@ -229,7 +227,6 @@ type CallingReduxInterface = Pick< export type SetPresentingOptionsType = Readonly<{ conversationId: string; - hasLocalVideo: boolean; mediaStream?: MediaStream; source?: PresentedSource; callLinkRootKey?: string; @@ -424,7 +421,6 @@ const GROUP_CALL_OPTIONS: GumVideoCaptureOptions = { export class CallingClass { readonly #videoCapturer: GumVideoCapturer; - readonly videoRenderer: CanvasVideoRenderer; #localPreviewContainer: HTMLDivElement | null = null; @@ -441,10 +437,11 @@ export class CallingClass { #lastMediaDeviceSettings?: MediaDeviceSettings; #deviceReselectionTimer?: NodeJS.Timeout; #callsLookup: { [key: string]: Call | GroupCall }; - #hadLocalVideoBeforePresenting?: boolean; #currentRtcStatsInterval: number | null = null; #callDebugNumber: number = 0; + #cameraEnabled: boolean = false; + // Send our profile key to other participants in call link calls to ensure they // can see our profile info. Only send once per aci until the next app start. #sendProfileKeysForAdhocCallCache: Set; @@ -1062,9 +1059,13 @@ export class CallingClass { type: 'ProfileKeyForCall', }); - RingRTC.setOutgoingAudio(call.callId, hasLocalAudio); - RingRTC.setVideoCapturer(call.callId, this.#videoCapturer); - RingRTC.setVideoRenderer(call.callId, this.videoRenderer); + // Set the camera disposition as we transition from the lobby to the outgoing call. + this.#cameraEnabled = hasLocalVideo; + + // Set the initial state for outgoing media for the outgoing call. + call.setOutgoingAudioMuted(!hasLocalAudio); + call.setOutgoingVideoMuted(!hasLocalVideo); + this.#attachToCall(conversation, call); this.#reduxInterface.outgoingCall({ @@ -2080,6 +2081,12 @@ export class CallingClass { }); log.info(logId); + const call = getOwn(this.#callsLookup, conversationId); + if (!call || !(call instanceof Call)) { + log.warn(`${logId}: Trying to accept a non-existent call`); + return; + } + const callId = this.#getCallIdForConversation(conversationId); if (!callId) { log.warn(`${logId}: Trying to accept a non-existent call`); @@ -2093,9 +2100,20 @@ export class CallingClass { hasLocalVideo: asVideoCall, }); await this.#startDeviceReselectionTimer(); - RingRTC.setVideoCapturer(callId, this.#videoCapturer); - RingRTC.setVideoRenderer(callId, this.videoRenderer); - RingRTC.accept(callId, asVideoCall); + + if (asVideoCall) { + // Warm up the camera as soon as possible. + drop(this.enableLocalCamera(CallMode.Direct)); + } + + // Set the starting camera disposition based on the type of call. + this.#cameraEnabled = asVideoCall; + + // Set the initial state for outgoing media for the incoming call. + call.setOutgoingAudioMuted(false); + call.setOutgoingVideoMuted(!asVideoCall); + + RingRTC.accept(callId); } else { log.info( `${logId}: Permissions were denied, call not allowed, hanging up.` @@ -2168,6 +2186,11 @@ export class CallingClass { entries.forEach(([callConversationId, call]) => { log.info(`${logId}: Hanging up conversation ${callConversationId}`); if (call instanceof Call) { + // Stop media immediately upon hangup. + this.disableLocalVideo(); + this.videoRenderer.disable(); + call.setOutgoingAudioMuted(true); + call.setOutgoingVideoMuted(true); RingRTC.hangup(call.callId); } else if (call instanceof GroupCall) { // This ensures that we turn off our devices. @@ -2197,7 +2220,7 @@ export class CallingClass { } if (call instanceof Call) { - RingRTC.setOutgoingAudio(call.callId, enabled); + call.setOutgoingAudioMuted(!enabled); } else if (call instanceof GroupCall) { call.setOutgoingAudioMuted(!enabled); } else { @@ -2223,8 +2246,17 @@ export class CallingClass { ); } + this.#cameraEnabled = enabled; + if (call instanceof Call) { - RingRTC.setOutgoingVideo(call.callId, enabled); + if (enabled) { + // Start sending video from the camera. + await this.enableCaptureAndSend(call); + } else { + // Stop the camera. + this.disableLocalVideo(); + } + call.setOutgoingVideoMuted(!enabled); } else if (call instanceof GroupCall) { call.setOutgoingVideoMuted(!enabled); } else { @@ -2237,7 +2269,7 @@ export class CallingClass { mediaStream: MediaStream ): Promise { if (call instanceof Call) { - RingRTC.setOutgoingVideoIsScreenShare(call.callId, true); + call.setOutgoingVideoIsScreenShare(true); } else if (call instanceof GroupCall) { call.setOutgoingVideoIsScreenShare(true); call.setPresenting(true); @@ -2258,7 +2290,7 @@ export class CallingClass { // Enable the video transmission once the stream is running if (call instanceof Call) { - RingRTC.setOutgoingVideo(call.callId, true); + call.setOutgoingVideoMuted(false); } else if (call instanceof GroupCall) { call.setOutgoingVideoMuted(false); } else { @@ -2266,19 +2298,22 @@ export class CallingClass { } } - async #stopPresenting( - call: Call | GroupCall, - hasLocalVideo: boolean - ): Promise { + async #stopPresenting(call: Call | GroupCall): Promise { if (call instanceof Call) { // Disable video transmission first - RingRTC.setOutgoingVideo(call.callId, hasLocalVideo); + call.setOutgoingVideoMuted(!this.#cameraEnabled); // Stop screenshare - RingRTC.setOutgoingVideoIsScreenShare(call.callId, false); + call.setOutgoingVideoIsScreenShare(false); + + if (this.#cameraEnabled) { + // Start sending video from the camera since it was enabled + // prior to screensharing + await this.enableCaptureAndSend(call); + } } else if (call instanceof GroupCall) { // Ditto - call.setOutgoingVideoMuted(!hasLocalVideo); + call.setOutgoingVideoMuted(!this.#cameraEnabled); call.setOutgoingVideoIsScreenShare(false); call.setPresenting(false); @@ -2289,7 +2324,6 @@ export class CallingClass { async setPresenting({ conversationId, - hasLocalVideo, mediaStream, source, callLinkRootKey, @@ -2300,16 +2334,13 @@ export class CallingClass { return; } - this.#videoCapturer.disable(); + this.disableLocalVideo(); + const isPresenting = mediaStream != null; if (isPresenting) { - this.#hadLocalVideoBeforePresenting = hasLocalVideo; await this.#startPresenting(call, mediaStream); } else { - const prevHasLocalVideo = - this.#hadLocalVideoBeforePresenting ?? hasLocalVideo; - this.#hadLocalVideoBeforePresenting = undefined; - await this.#stopPresenting(call, prevHasLocalVideo); + await this.#stopPresenting(call); } if (isPresenting) { @@ -3243,9 +3274,19 @@ export class CallingClass { call.handleStateChanged = async () => { if (call.state === CallState.Accepted) { acceptedTime = acceptedTime ?? Date.now(); - } + // Start rendering received video frames. + this.videoRenderer.enable(call); + if (this.#cameraEnabled) { + // Start sending video from the camera (if not already). + await this.enableCaptureAndSend(call); + } + } if (call.state === CallState.Ended) { + // Stop media since the call has ended. + this.disableLocalVideo(); + this.videoRenderer.disable(); + this.#stopDeviceReselectionTimer(); this.#lastMediaDeviceSettings = undefined; delete this.#callsLookup[conversationId]; diff --git a/ts/state/ducks/calling.ts b/ts/state/ducks/calling.ts index 224a93a3d4..bd0077a13d 100644 --- a/ts/state/ducks/calling.ts +++ b/ts/state/ducks/calling.ts @@ -2014,7 +2014,6 @@ function _setPresenting( await calling.setPresenting({ conversationId: activeCall.conversationId, - hasLocalVideo: activeCallState.hasLocalVideo, mediaStream, source: sourceToPresent, callLinkRootKey: rootKey, diff --git a/ts/test-electron/state/ducks/calling_test.ts b/ts/test-electron/state/ducks/calling_test.ts index 45e8a489a0..df0a261abe 100644 --- a/ts/test-electron/state/ducks/calling_test.ts +++ b/ts/test-electron/state/ducks/calling_test.ts @@ -306,7 +306,6 @@ describe('calling duck', () => { sinon.assert.calledOnce(this.callingServiceSetPresenting); sinon.assert.calledWith(this.callingServiceSetPresenting, { conversationId: 'fake-group-call-conversation-id', - hasLocalVideo: false, mediaStream: undefined, source: presentedSource, callLinkRootKey: undefined,