Group Calls: Fix connection & UI issues (#3427)

This commit is contained in:
Alexander Zinchuk 2023-07-05 13:15:56 +02:00
parent a5486b9ada
commit 3b0f03e012
18 changed files with 297 additions and 85 deletions

View File

@ -176,7 +176,10 @@ export async function joinGroupCall({
data: JSON.stringify(params),
}),
inviteHash,
}));
}), {
shouldRetryOnTimeout: true,
abortControllerGroup: 'call',
});
if (!result) return undefined;

View File

@ -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(),

View File

@ -1,5 +1,5 @@
export {
destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests,
destroy, disconnect, downloadMedia, fetchCurrentUser, repairFileReference, abortChatRequests, abortRequestGroup,
} from './client';
export {

View File

@ -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;
}
}

View File

@ -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

View File

@ -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);

View File

@ -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;
}

View File

@ -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

View File

@ -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);

View File

@ -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}

View File

@ -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,

View File

@ -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();

View File

@ -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,
);

View File

@ -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(() => {

View File

@ -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>;

View File

@ -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;

View File

@ -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,

View File

@ -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!,