diff --git a/src/api/gramjs/methods/calls.ts b/src/api/gramjs/methods/calls.ts index 1d9a88f6c..ee3de8f02 100644 --- a/src/api/gramjs/methods/calls.ts +++ b/src/api/gramjs/methods/calls.ts @@ -176,7 +176,10 @@ export async function joinGroupCall({ data: JSON.stringify(params), }), inviteHash, - })); + }), { + shouldRetryOnTimeout: true, + abortControllerGroup: 'call', + }); if (!result) return undefined; diff --git a/src/api/gramjs/methods/client.ts b/src/api/gramjs/methods/client.ts index d1e794c88..7022fab1c 100644 --- a/src/api/gramjs/methods/client.ts +++ b/src/api/gramjs/methods/client.ts @@ -42,6 +42,7 @@ GramJsLogger.setLevel(DEBUG_GRAMJS ? 'debug' : 'warn'); const gramJsUpdateEventBuilder = { build: (update: object) => update }; const CHAT_ABORT_CONTROLLERS = new Map(); +const ABORT_CONTROLLERS = new Map(); let onUpdate: OnApiUpdate; let client: TelegramClient; @@ -211,6 +212,8 @@ type InvokeRequestParams = { shouldIgnoreErrors?: boolean; abortControllerChatId?: string; abortControllerThreadId?: number; + abortControllerGroup?: 'call'; + shouldRetryOnTimeout?: boolean; }; export async function invokeRequest( @@ -229,6 +232,7 @@ export async function invokeRequest( ) { const { shouldThrow, shouldIgnoreUpdates, dcId, shouldIgnoreErrors, abortControllerChatId, abortControllerThreadId, + shouldRetryOnTimeout, abortControllerGroup, } = params; const shouldReturnTrue = Boolean(params.shouldReturnTrue); @@ -243,12 +247,21 @@ export async function invokeRequest( abortSignal = abortControllerThreadId ? controller.getThreadSignal(abortControllerThreadId) : controller.signal; } + if (abortControllerGroup) { + let controller = ABORT_CONTROLLERS.get(abortControllerGroup); + if (!controller) { + controller = new AbortController(); + ABORT_CONTROLLERS.set(abortControllerGroup, controller); + } + abortSignal = controller.signal; + } + try { if (DEBUG) { log('INVOKE', request.className); } - const result = await client.invoke(request, dcId, abortSignal); + const result = await client.invoke(request, dcId, abortSignal, shouldRetryOnTimeout); if (DEBUG) { log('RESPONSE', request.className, result); @@ -326,6 +339,11 @@ export function abortChatRequests(params: { chatId: string; threadId?: number }) controller?.abortThread(threadId, 'Thread change'); } +export function abortRequestGroup(group: string) { + ABORT_CONTROLLERS.get(group)?.abort(); + ABORT_CONTROLLERS.delete(group); +} + export async function fetchCurrentUser() { const userFull = await invokeRequest(new GramJs.users.GetFullUser({ id: new GramJs.InputUserSelf(), diff --git a/src/api/gramjs/methods/index.ts b/src/api/gramjs/methods/index.ts index 7c02799b9..0963cc0da 100644 --- a/src/api/gramjs/methods/index.ts +++ b/src/api/gramjs/methods/index.ts @@ -1,5 +1,5 @@ export { - destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests, + destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests, abortRequestGroup, } from './client'; export { diff --git a/src/components/calls/group/GroupCall.module.scss b/src/components/calls/group/GroupCall.module.scss index 1857dfa94..e6d9cb57d 100644 --- a/src/components/calls/group/GroupCall.module.scss +++ b/src/components/calls/group/GroupCall.module.scss @@ -1,6 +1,7 @@ .root { --group-call-panel-color: #212121; --group-call-panel-header-border-color: #3b3b3b; + --color-dividers: var(--group-call-panel-header-border-color); --group-call-background-color: #000000; --green-button-color: rgba(1, 200, 80, 0.3); --blue-button-color: rgb(60, 135, 247, 0.2); @@ -148,6 +149,10 @@ width: calc(100% - var(--default-width)); } +.mainVideoContainer { + position: absolute; +} + .videosHeader { display: flex; align-items: center; @@ -160,7 +165,7 @@ .videosContent { flex-grow: 1; - margin: 0.5rem 0.625rem; + margin: 0.1875rem 0.625rem 0.5rem; } .actions { @@ -175,7 +180,6 @@ z-index: 2; } - .actionButton { width: 3.375rem !important; height: 3.375rem !important; @@ -242,11 +246,11 @@ border-radius: 1.25rem; padding: 0.75rem; bottom: 2.5rem; - transition: var(--layer-transition) transform, 250ms ease-in-out opacity; + transition: 250ms ease-in-out opacity; opacity: 0; } - .videos:hover ~ .actions, .video:hover ~ .actions, .actions:hover { + .videos:hover ~ .actions, .mainVideoContainer:hover ~ .actions, .actions:hover { opacity: 1; } } diff --git a/src/components/calls/group/GroupCall.tsx b/src/components/calls/group/GroupCall.tsx index a05e210fa..05f1ad491 100644 --- a/src/components/calls/group/GroupCall.tsx +++ b/src/components/calls/group/GroupCall.tsx @@ -152,7 +152,7 @@ const GroupCall: FC = ({ }); const handleToggleFullscreen = useLastCallback(() => { - if (!containerRef.current) return; + if (!containerRef.current || isMobile) return; if (isFullscreen) { closeFullscreen(); @@ -169,6 +169,10 @@ const GroupCall: FC = ({ } }); + const handleToggleGroupCallPanel = useLastCallback(() => { + toggleGroupCallPanel(); + }); + const handleInviteMember = useLastCallback(() => { createGroupCallInviteLink(); }); @@ -241,7 +245,7 @@ const GroupCall: FC = ({ onClose={toggleGroupCallPanel} className={buildClassName( styles.root, - isFullscreen && styles.fullscreen, + (isFullscreen || isMobile) && styles.fullscreen, isLandscapeLayout && styles.landscape, !hasVideoParticipants && styles.noVideoParticipants, !isLandscapeLayout && styles.portrait, @@ -253,19 +257,21 @@ const GroupCall: FC = ({ {isLandscapeWithVideos && (
- + {!isMobile && ( + + )}

{title || lang('VoipGroupVoiceChat')} @@ -297,7 +303,7 @@ const GroupCall: FC = ({
- {!isLandscapeWithVideos && ( + {!isLandscapeWithVideos && !isMobile && ( )} + {isMobile && ( + + )} + {isLandscapeWithVideos && (
- {videoLayout.map((layout) => { - const participant = participants[layout.participantId]; - if (layout.isRemounted || !participant) { +
+ {videoLayout.map((layout) => { + const participant = participants[layout.participantId]; + if (layout.isRemounted || !participant) { + return ( +
+ ); + } return ( -
); - } - return ( - - ); - })} + })} +
+
+ )} +
= ({ )} -
-
- -
{status}
+ {!shouldHidePresentation && ( +
+
+ +
{status}
+
+
- -
+ )}
= ({ }; }, [groupCall?.id, groupCall?.isLoaded, isActive, subscribeToGroupCallUpdates]); - if (!groupCall) return undefined; + const { + shouldRender, + transitionClassNames, + } = useShowTransition(Boolean(groupCall && isActive)); + + const renderingParticipantCount = useCurrentOrPrev(groupCall?.participantsCount, true); + const renderingFetchedParticipants = useCurrentOrPrev(fetchedParticipants, true); + + if (!shouldRender) return undefined; return (
{lang('VoipGroupVoiceChat')} - {lang('Participants', groupCall.participantsCount || 0, 'i')} + {lang('Participants', renderingParticipantCount ?? 0, 'i')}
- {fetchedParticipants.map((peer) => ( + {renderingFetchedParticipants?.map((peer) => ( videosCount; const width = (containerWidth - (columns - 1) * PADDING_HORIZONTAL) / columns; const height = (containerHeight - (rows - 1) * PADDING_VERTICAL) / rows; + const lastRowWidth = width * (videosCount % columns); for (let i = 0; i < videosCount; i++) { - const x = initialX + (i % columns) * (width + PADDING_HORIZONTAL); + const row = Math.floor(i / columns); + const shouldCenter = shouldFillLastRow && row === rows - 1; + const x = initialX + (i % columns) * (width + PADDING_HORIZONTAL) + + (shouldCenter ? (containerWidth - lastRowWidth) / 2 : 0); const y = initialY + Math.floor(i / columns) * (height + PADDING_VERTICAL); layout.push({ x, diff --git a/src/global/actions/api/calls.async.ts b/src/global/actions/api/calls.async.ts index ac9604a94..60e750fdd 100644 --- a/src/global/actions/api/calls.async.ts +++ b/src/global/actions/api/calls.async.ts @@ -34,17 +34,26 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise( global = updateGroupCall( global, groupCall.id!, - omit(groupCall, ['connectionState']), + omit(groupCall, ['connectionState', 'isLoaded']), undefined, existingGroupCall ? undefined : groupCall.participantsCount, ); diff --git a/src/global/actions/ui/calls.ts b/src/global/actions/ui/calls.ts index 369c6ac81..5275d0c5f 100644 --- a/src/global/actions/ui/calls.ts +++ b/src/global/actions/ui/calls.ts @@ -284,7 +284,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise( tabId, }); } else { + stream.getTracks().forEach((track) => track.stop()); checkMicrophonePermission(global, actions, tabId); } }) @@ -485,6 +494,8 @@ function checkMicrophonePermission( message: langProvider.translate('RequestAcces.Error.HaveNotAccess.Call'), tabId, }); + } else { + stream.getTracks().forEach((track) => track.stop()); } }) .catch(() => { diff --git a/src/lib/gramjs/client/TelegramClient.d.ts b/src/lib/gramjs/client/TelegramClient.d.ts index cb091b756..36b83b66e 100644 --- a/src/lib/gramjs/client/TelegramClient.d.ts +++ b/src/lib/gramjs/client/TelegramClient.d.ts @@ -11,7 +11,7 @@ declare class TelegramClient { async start(authParams: UserAuthParams | BotAuthParams); async invoke( - request: R, dcId?: number, abortSignal?: AbortSignal, + request: R, dcId?: number, abortSignal?: AbortSignal, shouldRetryOnTimeout?: boolean, ): Promise; async uploadFile(uploadParams: UploadFileParams): ReturnType; diff --git a/src/lib/gramjs/client/TelegramClient.js b/src/lib/gramjs/client/TelegramClient.js index 9858155da..80a2787dc 100644 --- a/src/lib/gramjs/client/TelegramClient.js +++ b/src/lib/gramjs/client/TelegramClient.js @@ -866,10 +866,12 @@ class TelegramClient { * Invokes a MTProtoRequest (sends and receives it) and returns its result * @param request * @param dcId Optional dcId to use when sending the request + * @param abortSignal Optional AbortSignal to cancel the request + * @param shouldRetryOnTimeout Whether to retry the request if it times out * @returns {Promise} */ - async invoke(request, dcId, abortSignal) { + async invoke(request, dcId, abortSignal, shouldRetryOnTimeout = false) { if (request.classType !== 'request') { throw new Error('You can only invoke MTProtoRequests'); } @@ -918,6 +920,11 @@ class TelegramClient { await this.disconnect(); await sleep(2000); await this.connect(); + } else if (e instanceof errors.TimedOutError) { + if (!shouldRetryOnTimeout) { + state.finished.resolve(); + throw e; + } } else { state.finished.resolve(); throw e; diff --git a/src/lib/gramjs/errors/RPCErrorList.js b/src/lib/gramjs/errors/RPCErrorList.js index 65ea7dbd6..2b13eb748 100644 --- a/src/lib/gramjs/errors/RPCErrorList.js +++ b/src/lib/gramjs/errors/RPCErrorList.js @@ -3,6 +3,7 @@ const { InvalidDCError, FloodError, BadRequestError, + TimedOutError, } = require('./RPCBaseErrors'); class UserMigrateError extends InvalidDCError { @@ -102,7 +103,7 @@ const rpcErrorRe = [ [/USER_MIGRATE_(\d+)/, UserMigrateError], [/NETWORK_MIGRATE_(\d+)/, NetworkMigrateError], [/EMAIL_UNCONFIRMED_(\d+)/, EmailUnconfirmedError], - + [/^Timeout$/, TimedOutError], ]; module.exports = { rpcErrorRe, diff --git a/src/lib/secret-sauce/secretsauce.ts b/src/lib/secret-sauce/secretsauce.ts index 23a764f39..5d340134b 100644 --- a/src/lib/secret-sauce/secretsauce.ts +++ b/src/lib/secret-sauce/secretsauce.ts @@ -11,6 +11,8 @@ import { IS_NOISE_SUPPRESSION_SUPPORTED, THRESHOLD, } from './utils'; +import Deferred from "../../util/Deferred"; +import safePlay from "../../util/safePlay"; export type StreamType = 'audio' | 'video' | 'presentation'; const DEFAULT_MID = 3; @@ -46,6 +48,9 @@ type GroupCallState = { audioContext?: AudioContext; mediaStream?: MediaStream; lastMid: number; + audioStream?: MediaStream; + audioSource?: MediaStreamAudioSourceNode; + audioAnalyser?: AnalyserNode; }; let state: GroupCallState | undefined; @@ -142,7 +147,11 @@ function updateGroupCallStreams(userId: string) { }); } -function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') { +async function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'user') { + if(streamType === 'audio' && state?.audioStream) { + return state.audioStream; + } + if (streamType === 'presentation') { return (navigator.mediaDevices as any).getDisplayMedia({ audio: false, @@ -150,7 +159,7 @@ function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'us }); } - return navigator.mediaDevices.getUserMedia({ + const media = await navigator.mediaDevices.getUserMedia({ audio: streamType === 'audio' ? { // @ts-ignore ...(IS_ECHO_CANCELLATION_SUPPORTED && { echoCancellation: true }), @@ -159,8 +168,22 @@ function getUserStream(streamType: StreamType, facing: VideoFacingModeEnum = 'us video: streamType === 'video' ? { facingMode: facing, } : false, - }); + + if(state && streamType === 'audio'){ + state.audioStream = media; + } + + if(streamType === 'video') { + const vid = document.createElement('video'); + vid.srcObject = media; + + const deferred = new Deferred(); + vid.oncanplay = () => deferred.resolve(); + await deferred.promise; + } + + return media; } export async function switchCameraInput() { @@ -230,9 +253,9 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde } else if (streamType === 'audio') { const { audioContext } = state; if (!audioContext) return; - const source = audioContext.createMediaStreamSource(newStream); + const source = state.audioSource || audioContext.createMediaStreamSource(newStream); - const analyser = audioContext.createAnalyser(); + const analyser = state.audioAnalyser || audioContext.createAnalyser(); analyser.minDecibels = -100; analyser.maxDecibels = -30; analyser.smoothingTimeConstant = 0.05; @@ -242,6 +265,8 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde state = { ...state, + audioSource: source, + audioAnalyser: analyser, participantFunctions: { ...state.participantFunctions, [state.myId]: { @@ -256,7 +281,6 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde }; } } else if (!value && track.enabled) { - track.stop(); const newStream = streamType === 'audio' ? state.silence : state.black; if (!newStream) return; @@ -265,6 +289,14 @@ export async function toggleStream(streamType: StreamType, value: boolean | unde if (streamType === 'video') { state.facingMode = undefined; } + + if(streamType !== 'audio') { + // We only want to stop video streams + track.stop(); + } else { + state.audioSource?.disconnect(); + state.audioAnalyser?.disconnect(); + } } updateGroupCallStreams(state.myId!); if (streamType === 'presentation' && !value) leavePresentation(true); @@ -292,6 +324,10 @@ export function leaveGroupCall() { }); }); } + + state.audioStream?.getTracks().forEach((track) => { + track.stop(); + }); leavePresentation(true); state.dataChannel?.close(); state.connection?.close(); @@ -679,6 +715,7 @@ function initializeConnection( if (!isPresentation) { connection.oniceconnectionstatechange = () => { const connectionState = connection.iceConnectionState; + console.log('iceconnectionstatechange', connectionState); if (connectionState === 'connected' || connectionState === 'completed') { updateConnectionState('connected'); } else if (connectionState === 'checking' || connectionState === 'new') { @@ -688,10 +725,15 @@ function initializeConnection( } }; } + connection.onconnectionstatechange = () => { + console.log('connectionstatechange', connection.connectionState); + } connection.ontrack = handleTrack; connection.onnegotiationneeded = async () => { if (!state) return; + console.log('onnegotiationneeded'); + const { myId } = state; if (!myId) { @@ -701,8 +743,10 @@ function initializeConnection( offerToReceiveVideo: true, offerToReceiveAudio: !isPresentation, }); + console.log('offer created'); await connection.setLocalDescription(offer); + console.log('local desc set'); if (!offer.sdp) { return; @@ -827,7 +871,7 @@ export function joinGroupCall( const mediaStream = new MediaStream(); audioElement.srcObject = mediaStream; - audioElement.play().catch((l) => console.warn(l)); + safePlay(audioElement); state = { onUpdate, @@ -845,6 +889,9 @@ export function joinGroupCall( lastMid: DEFAULT_MID, }; + // Prepare microphone + getUserStream('audio'); + return new Promise((resolve) => { state = { ...state!,