Group Calls: Fix connection & UI issues (#3427)
This commit is contained in:
parent
a5486b9ada
commit
3b0f03e012
@ -176,7 +176,10 @@ export async function joinGroupCall({
|
||||
data: JSON.stringify(params),
|
||||
}),
|
||||
inviteHash,
|
||||
}));
|
||||
}), {
|
||||
shouldRetryOnTimeout: true,
|
||||
abortControllerGroup: 'call',
|
||||
});
|
||||
|
||||
if (!result) return undefined;
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ GramJsLogger.setLevel(DEBUG_GRAMJS ? 'debug' : 'warn');
|
||||
const gramJsUpdateEventBuilder = { build: (update: object) => update };
|
||||
|
||||
const CHAT_ABORT_CONTROLLERS = new Map<string, ChatAbortController>();
|
||||
const ABORT_CONTROLLERS = new Map<string, AbortController>();
|
||||
|
||||
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<T extends GramJs.AnyRequest>(
|
||||
@ -229,6 +232,7 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
) {
|
||||
const {
|
||||
shouldThrow, shouldIgnoreUpdates, dcId, shouldIgnoreErrors, abortControllerChatId, abortControllerThreadId,
|
||||
shouldRetryOnTimeout, abortControllerGroup,
|
||||
} = params;
|
||||
const shouldReturnTrue = Boolean(params.shouldReturnTrue);
|
||||
|
||||
@ -243,12 +247,21 @@ export async function invokeRequest<T extends GramJs.AnyRequest>(
|
||||
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(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export {
|
||||
destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests,
|
||||
destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests, abortRequestGroup,
|
||||
} from './client';
|
||||
|
||||
export {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,7 +152,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
});
|
||||
|
||||
const handleToggleFullscreen = useLastCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!containerRef.current || isMobile) return;
|
||||
|
||||
if (isFullscreen) {
|
||||
closeFullscreen();
|
||||
@ -169,6 +169,10 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
const handleToggleGroupCallPanel = useLastCallback(() => {
|
||||
toggleGroupCallPanel();
|
||||
});
|
||||
|
||||
const handleInviteMember = useLastCallback(() => {
|
||||
createGroupCallInviteLink();
|
||||
});
|
||||
@ -241,7 +245,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
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<OwnProps & StateProps> = ({
|
||||
{isLandscapeWithVideos && (
|
||||
<div className={styles.videos}>
|
||||
<div className={styles.videosHeader}>
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
onClick={handleToggleFullscreen}
|
||||
className={buildClassName(styles.headerButton, styles.firstButton)}
|
||||
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
|
||||
>
|
||||
<i
|
||||
className={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
{!isMobile && (
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
onClick={handleToggleFullscreen}
|
||||
className={buildClassName(styles.headerButton, styles.firstButton)}
|
||||
ariaLabel={lang(isFullscreen ? 'AccExitFullscreen' : 'AccSwitchToFullscreen')}
|
||||
>
|
||||
<i
|
||||
className={buildClassName('icon', isFullscreen ? 'icon-smallscreen' : 'icon-fullscreen')}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<h3 className={buildClassName(styles.title, styles.bigger)}>
|
||||
{title || lang('VoipGroupVoiceChat')}
|
||||
@ -297,7 +303,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
<div className={styles.panelScrollTrigger} ref={panelScrollTriggerRef} />
|
||||
|
||||
<div className={buildClassName(styles.panelHeader, hasScrolled && styles.scrolled)}>
|
||||
{!isLandscapeWithVideos && (
|
||||
{!isLandscapeWithVideos && !isMobile && (
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
@ -314,6 +320,22 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<Button
|
||||
round
|
||||
size="smaller"
|
||||
color="translucent"
|
||||
onClick={handleToggleGroupCallPanel}
|
||||
className={buildClassName(styles.headerButton, styles.firstButton)}
|
||||
ariaLabel={lang('Close')}
|
||||
>
|
||||
<i
|
||||
className={buildClassName('icon', 'icon-close')}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isLandscapeWithVideos && (
|
||||
<Button
|
||||
round
|
||||
@ -386,6 +408,7 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
setPinned={setPinnedVideo}
|
||||
pinnedVideo={pinnedVideo}
|
||||
participant={participant}
|
||||
onStopSharing={handleToggleGroupCallPresentation}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@ -408,29 +431,32 @@ const GroupCall: FC<OwnProps & StateProps> = ({
|
||||
</FloatingActionButton>
|
||||
</div>
|
||||
|
||||
{videoLayout.map((layout) => {
|
||||
const participant = participants[layout.participantId];
|
||||
if (layout.isRemounted || !participant) {
|
||||
<div className={styles.mainVideoContainer}>
|
||||
{videoLayout.map((layout) => {
|
||||
const participant = participants[layout.participantId];
|
||||
if (layout.isRemounted || !participant) {
|
||||
return (
|
||||
<div
|
||||
teactOrderKey={layout.orderKey}
|
||||
key={`${layout.participantId}_${layout.type}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
<GroupCallParticipantVideo
|
||||
teactOrderKey={layout.orderKey}
|
||||
key={`${layout.participantId}_${layout.type}`}
|
||||
layout={layout}
|
||||
canPin={canPinVideo}
|
||||
setPinned={setPinnedVideo}
|
||||
pinnedVideo={pinnedVideo}
|
||||
participant={participant}
|
||||
className={styles.video}
|
||||
onStopSharing={handleToggleGroupCallPresentation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<GroupCallParticipantVideo
|
||||
teactOrderKey={layout.orderKey}
|
||||
key={`${layout.participantId}_${layout.type}`}
|
||||
layout={layout}
|
||||
canPin={canPinVideo}
|
||||
setPinned={setPinnedVideo}
|
||||
pinnedVideo={pinnedVideo}
|
||||
participant={participant}
|
||||
className={styles.video}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
|
||||
@ -4,6 +4,8 @@
|
||||
--color-text: white;
|
||||
--color-background-compact-menu: #212121DD;
|
||||
--color-background-compact-menu-hover: #00000066;
|
||||
--color-background: #212121DD;
|
||||
--color-item-active: #00000066;
|
||||
|
||||
position: absolute;
|
||||
z-index: var(--z-modal-menu);
|
||||
|
||||
@ -52,8 +52,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
border-radius: 0.625rem;
|
||||
@ -84,7 +89,7 @@
|
||||
}
|
||||
|
||||
.flipped {
|
||||
transform: rotateY(180deg);
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.pinButton {
|
||||
@ -148,3 +153,38 @@
|
||||
.icon {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hidePresentation {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.ownPresentation {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgb(0, 0, 0, 0.6);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.ownPresentationText {
|
||||
max-width: 70%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stopSharingButton {
|
||||
width: auto;
|
||||
min-width: 6rem;
|
||||
font-weight: 500;
|
||||
max-height: 2.25rem;
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ import Button from '../../ui/Button';
|
||||
import OutlinedMicrophoneIcon from './OutlinedMicrophoneIcon';
|
||||
import FullNameTitle from '../../common/FullNameTitle';
|
||||
import GroupCallParticipantMenu from './GroupCallParticipantMenu';
|
||||
import Skeleton from '../../ui/Skeleton';
|
||||
|
||||
import styles from './GroupCallParticipantVideo.module.scss';
|
||||
|
||||
@ -38,6 +39,7 @@ type OwnProps = {
|
||||
canPin: boolean;
|
||||
participant: TypeGroupCallParticipant;
|
||||
className?: string;
|
||||
onStopSharing: VoidFunction;
|
||||
};
|
||||
|
||||
type StateProps = {
|
||||
@ -54,6 +56,7 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
participant,
|
||||
user,
|
||||
chat,
|
||||
onStopSharing,
|
||||
}) => {
|
||||
const lang = useLang();
|
||||
|
||||
@ -75,6 +78,7 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
const isSpeaking = (participant.amplitude || 0) > THRESHOLD;
|
||||
const isRaiseHand = Boolean(participant.raiseHandRating);
|
||||
const shouldFlipVideo = type === 'video' && participant.isSelf;
|
||||
const shouldHidePresentation = type === 'screen' && participant.isSelf;
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isSelf) {
|
||||
@ -141,13 +145,20 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
setIsHidden(false);
|
||||
}, []);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const handleCanPlay = useLastCallback(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// When video stream is removed, the video element starts showing empty black screen.
|
||||
// To avoid that, we hide the video element and show the fallback frame instead, which is constantly updated
|
||||
// every VIDEO_FALLBACK_UPDATE_INTERVAL milliseconds.
|
||||
useInterval(() => {
|
||||
if (!stream?.active) return;
|
||||
const video = videoRef.current!;
|
||||
const canvas = videoFallbackRef.current!;
|
||||
const video = videoRef.current;
|
||||
const canvas = videoFallbackRef.current;
|
||||
if (!video || !canvas) return;
|
||||
|
||||
requestMutation(() => {
|
||||
canvas.width = video.videoWidth;
|
||||
@ -255,20 +266,38 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
isSpeaking && styles.speaking,
|
||||
)}
|
||||
>
|
||||
{isLoading && (
|
||||
<Skeleton className={buildClassName(styles.video, styles.loader)} />
|
||||
)}
|
||||
{stream && (
|
||||
<video
|
||||
className={buildClassName(styles.video, shouldFlipVideo && styles.flipped)}
|
||||
className={buildClassName(
|
||||
styles.video, shouldFlipVideo && styles.flipped, shouldHidePresentation && styles.hidePresentation,
|
||||
)}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
srcObject={stream}
|
||||
ref={videoRef}
|
||||
onCanPlay={handleCanPlay}
|
||||
/>
|
||||
)}
|
||||
<canvas
|
||||
className={buildClassName(styles.videoFallback, shouldFlipVideo && styles.flipped)}
|
||||
ref={videoFallbackRef}
|
||||
/>
|
||||
{!shouldHidePresentation && (
|
||||
<canvas
|
||||
className={buildClassName(styles.videoFallback, shouldFlipVideo && styles.flipped)}
|
||||
ref={videoFallbackRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{shouldHidePresentation && (
|
||||
<div className={styles.ownPresentation}>
|
||||
<div className={styles.ownPresentationText}>{lang('VoiceChat.Sharing.Placeholder')}</div>
|
||||
<Button className={styles.stopSharingButton} onClick={onStopSharing}>
|
||||
{lang('VoiceChat.Sharing.Stop')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.thumbnailWrapper}>
|
||||
<canvas
|
||||
className={buildClassName(styles.thumbnail, shouldFlipVideo && styles.flipped)}
|
||||
@ -288,13 +317,15 @@ const GroupCallParticipantVideo: FC<OwnProps & StateProps> = ({
|
||||
<i className={buildClassName('icon', isPinned ? 'icon-unpin' : 'icon-pin')} />
|
||||
</Button>
|
||||
)}
|
||||
<div className={styles.bottomPanel}>
|
||||
<div className={styles.info}>
|
||||
<FullNameTitle peer={user || chat!} className={styles.name} />
|
||||
<div className={styles.status}>{status}</div>
|
||||
{!shouldHidePresentation && (
|
||||
<div className={styles.bottomPanel}>
|
||||
<div className={styles.info}>
|
||||
<FullNameTitle peer={user || chat!} className={styles.name} />
|
||||
<div className={styles.status}>{status}</div>
|
||||
</div>
|
||||
<OutlinedMicrophoneIcon participant={participant} className={styles.icon} noColor />
|
||||
</div>
|
||||
<OutlinedMicrophoneIcon participant={participant} className={styles.icon} noColor />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<GroupCallParticipantMenu
|
||||
|
||||
@ -26,10 +26,6 @@
|
||||
box-shadow: 0 0.125rem 0.125rem var(--color-light-shadow);
|
||||
}
|
||||
|
||||
&.is-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
&.has-pinned-offset {
|
||||
top: calc(100% + 2.875rem);
|
||||
|
||||
@ -10,6 +10,8 @@ import { selectChatGroupCall } from '../../../global/selectors/calls';
|
||||
import buildClassName from '../../../util/buildClassName';
|
||||
import { selectChat, selectTabState } from '../../../global/selectors';
|
||||
import useLang from '../../../hooks/useLang';
|
||||
import useShowTransition from '../../../hooks/useShowTransition';
|
||||
import useCurrentOrPrev from '../../../hooks/useCurrentOrPrev';
|
||||
|
||||
import Button from '../../ui/Button';
|
||||
import Avatar from '../../common/Avatar';
|
||||
@ -83,24 +85,32 @@ const GroupCallTopPane: FC<OwnProps & StateProps> = ({
|
||||
};
|
||||
}, [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 (
|
||||
<div
|
||||
className={buildClassName(
|
||||
'GroupCallTopPane',
|
||||
hasPinnedOffset && 'has-pinned-offset',
|
||||
!isActive && 'is-hidden',
|
||||
className,
|
||||
transitionClassNames,
|
||||
)}
|
||||
onClick={handleJoinGroupCall}
|
||||
>
|
||||
<div className="info">
|
||||
<span className="title">{lang('VoipGroupVoiceChat')}</span>
|
||||
<span className="participants">{lang('Participants', groupCall.participantsCount || 0, 'i')}</span>
|
||||
<span className="participants">{lang('Participants', renderingParticipantCount ?? 0, 'i')}</span>
|
||||
</div>
|
||||
<div className="avatars">
|
||||
{fetchedParticipants.map((peer) => (
|
||||
{renderingFetchedParticipants?.map((peer) => (
|
||||
<Avatar
|
||||
key={peer.id}
|
||||
peer={peer}
|
||||
|
||||
@ -174,11 +174,17 @@ export default function useGroupCallVideoLayout({
|
||||
|
||||
const columns = calculateColumnsCount(videosCount);
|
||||
const rows = Math.ceil(videosCount / columns);
|
||||
const totalGridSize = rows * columns;
|
||||
const shouldFillLastRow = totalGridSize > 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,
|
||||
|
||||
@ -34,17 +34,26 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise<voi
|
||||
isFromLibrary, shouldDiscard, shouldRemove, rejoin,
|
||||
tabId = getCurrentTabId(),
|
||||
} = payload || {};
|
||||
|
||||
const groupCall = selectActiveGroupCall(global);
|
||||
if (!groupCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
global = updateActiveGroupCall(global, { connectionState: 'disconnected' }, groupCall.participantsCount - 1);
|
||||
global = {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
activeGroupCallId: undefined,
|
||||
},
|
||||
};
|
||||
setGlobal(global);
|
||||
|
||||
await callApi('leaveGroupCall', {
|
||||
call: groupCall,
|
||||
});
|
||||
await callApi('abortRequestGroup', 'call');
|
||||
|
||||
if (shouldDiscard) {
|
||||
await callApi('discardGroupCall', {
|
||||
@ -59,13 +68,6 @@ addActionHandler('leaveGroupCall', async (global, actions, payload): Promise<voi
|
||||
|
||||
removeGroupCallAudioElement();
|
||||
|
||||
global = {
|
||||
...global,
|
||||
groupCalls: {
|
||||
...global.groupCalls,
|
||||
activeGroupCallId: undefined,
|
||||
},
|
||||
};
|
||||
setGlobal(global);
|
||||
|
||||
actions.toggleGroupCallPanel({ force: undefined, tabId });
|
||||
@ -221,7 +223,15 @@ addActionHandler('connectToActiveGroupCall', async (global, actions, payload): P
|
||||
|
||||
global = getGlobal();
|
||||
|
||||
if (!result) return;
|
||||
if (!result) {
|
||||
actions.showNotification({
|
||||
// TODO[lang] Localize error message
|
||||
message: 'Failed to join voice chat',
|
||||
tabId,
|
||||
});
|
||||
actions.leaveGroupCall({ tabId });
|
||||
return;
|
||||
}
|
||||
|
||||
actions.loadMoreGroupCallParticipants();
|
||||
|
||||
|
||||
@ -2322,7 +2322,7 @@ export async function loadFullChat<T extends GlobalState>(
|
||||
global = updateGroupCall(
|
||||
global,
|
||||
groupCall.id!,
|
||||
omit(groupCall, ['connectionState']),
|
||||
omit(groupCall, ['connectionState', 'isLoaded']),
|
||||
undefined,
|
||||
existingGroupCall ? undefined : groupCall.participantsCount,
|
||||
);
|
||||
|
||||
@ -284,7 +284,7 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
||||
const { groupCalls: { activeGroupCallId } } = global;
|
||||
let groupCall = id ? selectGroupCall(global, id) : selectChatGroupCall(global, chatId!);
|
||||
|
||||
if (groupCall?.id === activeGroupCallId) {
|
||||
if (groupCall && groupCall.id === activeGroupCallId) {
|
||||
actions.toggleGroupCallPanel({ tabId });
|
||||
return;
|
||||
}
|
||||
@ -304,7 +304,15 @@ addActionHandler('joinGroupCall', async (global, actions, payload): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groupCall && (!id || !accessHash)) {
|
||||
if (!groupCall && (!id || !accessHash) && chatId) {
|
||||
const chat = selectChat(global, chatId);
|
||||
|
||||
if (!chat) return;
|
||||
|
||||
await loadFullChat(global, actions, chat, tabId);
|
||||
global = getGlobal();
|
||||
groupCall = selectChatGroupCall(global, chatId);
|
||||
} else if (!groupCall && id && accessHash) {
|
||||
groupCall = await fetchGroupCall(global, {
|
||||
id,
|
||||
accessHash,
|
||||
@ -461,6 +469,7 @@ export function checkNavigatorUserMediaPermissions<T extends GlobalState>(
|
||||
tabId,
|
||||
});
|
||||
} else {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
checkMicrophonePermission(global, actions, tabId);
|
||||
}
|
||||
})
|
||||
@ -485,6 +494,8 @@ function checkMicrophonePermission<T extends GlobalState>(
|
||||
message: langProvider.translate('RequestAcces.Error.HaveNotAccess.Call'),
|
||||
tabId,
|
||||
});
|
||||
} else {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
2
src/lib/gramjs/client/TelegramClient.d.ts
vendored
@ -11,7 +11,7 @@ declare class TelegramClient {
|
||||
async start(authParams: UserAuthParams | BotAuthParams);
|
||||
|
||||
async invoke<R extends Api.AnyRequest>(
|
||||
request: R, dcId?: number, abortSignal?: AbortSignal,
|
||||
request: R, dcId?: number, abortSignal?: AbortSignal, shouldRetryOnTimeout?: boolean,
|
||||
): Promise<R['__response']>;
|
||||
|
||||
async uploadFile(uploadParams: UploadFileParams): ReturnType<typeof uploadFile>;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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!,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user