Start using LiveKit SDK for media devices

This version is not supposed to properly work, this is a work in
progress.

Main changes:
* Completely removed the PTT logic (for simplicity, it could be
  introduced later).
* Abstracted away the work with the media devices.
* Defined confined interfaces of the affected components so that they
  only get the data that they need without importing Matris JS SDK or
  LiveKit SDK, so that we can exchange their "backend" at any time.
* Started using JS/TS SDK from LiveKit as well as their React SDK to
  define the state of the local media devices and local streams.
This commit is contained in:
Daniel Abramov 2023-05-26 20:41:32 +02:00
commit f4f5c1ed31
22 changed files with 579 additions and 1670 deletions

View file

@ -18,6 +18,7 @@
},
"dependencies": {
"@juggle/resize-observer": "^3.3.1",
"@livekit/components-react": "^1.0.3",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz",
"@react-aria/button": "^3.3.4",
"@react-aria/dialog": "^3.1.4",
@ -45,7 +46,7 @@
"i18next": "^21.10.0",
"i18next-browser-languagedetector": "^6.1.8",
"i18next-http-backend": "^1.4.4",
"livekit-client": "^1.9.6",
"livekit-client": "^1.9.7",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#b1757b4f9dfe8a1fbb5b8d9ed697ff8b8516413e",
"matrix-widget-api": "^1.0.0",
"mermaid": "^9.4.0-rc.2",

View file

@ -1,89 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "matrix-js-sdk/src/logger";
/**
* Finds a media device with label matching 'deviceName'
* @param deviceName The label of the device to look for
* @param devices The list of devices to search
* @returns A matching media device or undefined if no matching device was found
*/
export async function findDeviceByName(
deviceName: string,
kind: MediaDeviceKind,
devices: MediaDeviceInfo[]
): Promise<string | undefined> {
const deviceInfo = devices.find(
(d) => d.kind === kind && d.label === deviceName
);
return deviceInfo?.deviceId;
}
/**
* Gets the available audio input/output and video input devices
* from the browser: a wrapper around mediaDevices.enumerateDevices()
* that requests a stream and holds it while calling enumerateDevices().
* This is because some browsers (Firefox) only return device labels when
* the app has an active user media stream. In Chrome, this will get a
* stream from the default camera which can mean, for example, that the
* light for the FaceTime camera turns on briefly even if you selected
* another camera. Once the Permissions API
* (https://developer.mozilla.org/en-US/docs/Web/API/Permissions_API)
* is ready for primetime, this should allow us to avoid this.
*
* @return The available media devices
*/
export async function getDevices(): Promise<MediaDeviceInfo[]> {
// First get the devices without their labels, to learn what kinds of streams
// we can request
let devices: MediaDeviceInfo[];
try {
devices = await navigator.mediaDevices.enumerateDevices();
} catch (error) {
logger.warn("Unable to refresh WebRTC devices", error);
devices = [];
}
let stream: MediaStream | null = null;
try {
if (devices.some((d) => d.kind === "audioinput")) {
// Holding just an audio stream will be enough to get us all device labels
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} else if (devices.some((d) => d.kind === "videoinput")) {
// We have to resort to a video stream
stream = await navigator.mediaDevices.getUserMedia({ video: true });
}
} catch (e) {
logger.info("Couldn't get media stream for enumerateDevices: failing");
throw e;
}
if (stream !== null) {
try {
return await navigator.mediaDevices.enumerateDevices();
} catch (error) {
logger.warn("Unable to refresh WebRTC devices", error);
} finally {
for (const track of stream.getTracks()) {
track.stop();
}
}
}
// If all else failed, continue without device labels
return devices;
}

View file

@ -1,43 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.preview {
margin: 20px 0;
padding: 24px 20px;
border-radius: 8px;
width: calc(100% - 40px);
max-width: 414px;
}
.inputField {
width: 100%;
}
.inputField:last-child {
margin-bottom: 0;
}
.microphonePermissions {
margin: 20px;
text-align: center;
}
@media (min-width: 800px) {
.preview {
margin-top: 40px;
background-color: #21262c;
}
}

View file

@ -1,100 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { Item } from "@react-stately/collections";
import { useTranslation } from "react-i18next";
import styles from "./AudioPreview.module.css";
import { SelectInput } from "../input/SelectInput";
import { Body } from "../typography/Typography";
interface Props {
state: GroupCallState;
roomName: string;
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
export function AudioPreview({
state,
roomName,
audioInput,
audioInputs,
setAudioInput,
audioOutput,
audioOutputs,
setAudioOutput,
}: Props) {
const { t } = useTranslation();
return (
<>
<h1>{t("{{roomName}} - Walkie-talkie call", { roomName })}</h1>
<div className={styles.preview}>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
{t("Microphone permissions needed to join the call.")}
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.microphonePermissions}>
{t("Accept microphone permissions to join the call.")}
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<>
<SelectInput
label={t("Microphone")}
selectedKey={audioInput}
onSelectionChange={setAudioInput}
className={styles.inputField}
>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Microphone {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
<SelectInput
label={t("Speaker")}
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
className={styles.inputField}
>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Speaker {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
)}
</>
)}
</div>
</>
);
}

View file

@ -18,23 +18,21 @@ import React, { useCallback, useEffect, useState } from "react";
import { useHistory } from "react-router-dom";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { logger } from "matrix-js-sdk/src/logger";
import { useTranslation } from "react-i18next";
import type { IWidgetApiRequest } from "matrix-widget-api";
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
import { widget, ElementWidgetActions } from "../widget";
import { useGroupCall } from "./useGroupCall";
import { ErrorView, FullScreenView } from "../FullScreenView";
import { LobbyView } from "./LobbyView";
import { MatrixInfo } from "./VideoPreview";
import { InCallView } from "./InCallView";
import { PTTCallView } from "./PTTCallView";
import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
import { useProfile } from "../profile/useProfile";
import { useLiveKit } from "./useLiveKit";
declare global {
interface Window {
@ -68,8 +66,6 @@ export function GroupCallView({
userMediaFeeds,
microphoneMuted,
localVideoMuted,
localCallFeed,
initLocalCallFeed,
enter,
leave,
toggleLocalVideoMuted,
@ -84,8 +80,6 @@ export function GroupCallView({
} = useGroupCall(groupCall);
const { t } = useTranslation();
const { setAudioInput, setVideoInput } = useMediaHandler();
const avatarUrl = useRoomAvatar(groupCall.room);
useEffect(() => {
window.groupCall = groupCall;
@ -94,54 +88,22 @@ export function GroupCallView({
};
}, [groupCall]);
const { displayName, avatarUrl } = useProfile(client);
const matrixInfo: MatrixInfo = {
userName: displayName,
avatarUrl,
roomName: groupCall.room.name,
roomId: roomIdOrAlias,
};
// TODO: Pass the correct URL and the correct JWT token here.
const lkState = useLiveKit("<SFU_URL_HERE>", "<JWT_TOKEN_HERE>");
useEffect(() => {
if (widget && preload) {
// In preload mode, wait for a join action before entering
const onJoin = async (ev: CustomEvent<IWidgetApiRequest>) => {
// Get the available devices so we can match the selected device
// to its ID. This involves getting a media stream (see docs on
// the function) so we only do it once and re-use the result.
const devices = await getDevices();
const { audioInput, videoInput } = ev.detail
.data as unknown as JoinCallData;
if (audioInput !== null) {
const deviceId = await findDeviceByName(
audioInput,
"audioinput",
devices
);
if (!deviceId) {
logger.warn("Unknown audio input: " + audioInput);
} else {
logger.debug(
`Found audio input ID ${deviceId} for name ${audioInput}`
);
setAudioInput(deviceId);
}
}
if (videoInput !== null) {
const deviceId = await findDeviceByName(
videoInput,
"videoinput",
devices
);
if (!deviceId) {
logger.warn("Unknown video input: " + videoInput);
} else {
logger.debug(
`Found video input ID ${deviceId} for name ${videoInput}`
);
setVideoInput(deviceId);
}
}
await Promise.all([
groupCall.setMicrophoneMuted(audioInput === null),
groupCall.setLocalVideoMuted(videoInput === null),
]);
await groupCall.enter();
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
@ -158,7 +120,7 @@ export function GroupCallView({
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
}
}, [groupCall, preload, setAudioInput, setVideoInput]);
}, [groupCall, preload]);
useEffect(() => {
if (isEmbedded && !preload) {
@ -225,22 +187,6 @@ export function GroupCallView({
if (error) {
return <ErrorView error={error} />;
} else if (state === GroupCallState.Entered) {
if (groupCall.isPtt) {
return (
<PTTCallView
client={client}
roomIdOrAlias={roomIdOrAlias}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
groupCall={groupCall}
participants={participants}
userMediaFeeds={userMediaFeeds}
onLeave={onLeave}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>
);
} else {
return (
<InCallView
groupCall={groupCall}
@ -248,6 +194,7 @@ export function GroupCallView({
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
participants={participants}
mediaDevices={lkState.mediaDevices}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
@ -264,7 +211,6 @@ export function GroupCallView({
hideHeader={hideHeader}
/>
);
}
} else if (left) {
if (isPasswordlessUser) {
return <CallEndedView client={client} />;
@ -282,25 +228,18 @@ export function GroupCallView({
<h1>{t("Loading room…")}</h1>
</FullScreenView>
);
} else {
} else if (lkState) {
return (
<LobbyView
client={client}
groupCall={groupCall}
roomName={groupCall.room.name}
avatarUrl={avatarUrl}
state={state}
onInitLocalCallFeed={initLocalCallFeed}
localCallFeed={localCallFeed}
matrixInfo={matrixInfo}
mediaDevices={lkState.mediaDevices}
localMedia={lkState.localMedia}
onEnter={enter}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
roomIdOrAlias={roomIdOrAlias}
isEmbedded={isEmbedded}
hideHeader={hideHeader}
/>
);
} else {
return null;
}
}

View file

@ -50,7 +50,6 @@ import { Avatar } from "../Avatar";
import { UserMenuContainer } from "../UserMenuContainer";
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
import { RageshakeRequestModal } from "./RageshakeRequestModal";
import { useMediaHandler } from "../settings/useMediaHandler";
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
import { useModalTriggerState } from "../Modal";
import { useAudioContext } from "../video-grid/useMediaStream";
@ -64,6 +63,7 @@ import { ParticipantInfo } from "./useGroupCall";
import { TileDescriptor } from "../video-grid/TileDescriptor";
import { AudioSink } from "../video-grid/AudioSink";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { MediaDevicesState } from "./devices/useMediaDevices";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
@ -77,6 +77,7 @@ interface Props {
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
roomName: string;
avatarUrl: string;
mediaDevices: MediaDevicesState;
microphoneMuted: boolean;
localVideoMuted: boolean;
toggleLocalVideoMuted: () => void;
@ -99,6 +100,7 @@ export function InCallView({
participants,
roomName,
avatarUrl,
mediaDevices,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
@ -349,7 +351,6 @@ export function InCallView({
// audio rendering for feeds that we're displaying, which will need to be fixed
// once we start having more participants than we can fit on a screen, but this
// is a workaround for now.
const { audioOutput } = useMediaHandler();
const audioElements: JSX.Element[] = [];
if (!spatialAudio || maximisedParticipant) {
for (const item of items) {
@ -357,7 +358,7 @@ export function InCallView({
audioElements.push(
<AudioSink
tileDescriptor={item}
audioOutput={audioOutput}
audioOutput="AUDIO OUTPUT?"
key={item.id}
/>
);
@ -389,9 +390,9 @@ export function InCallView({
)}
{!maximisedParticipant && (
<OverflowMenu
roomId={roomIdOrAlias}
mediaDevices={mediaDevices}
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={joinRule === JoinRule.Public}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}

View file

@ -14,90 +14,50 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useRef } from "react";
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React from "react";
import { PressEvent } from "@react-types/shared";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { Trans, useTranslation } from "react-i18next";
import styles from "./LobbyView.module.css";
import { Button, CopyButton } from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { useCallFeed } from "../video-grid/useCallFeed";
import { getRoomUrl } from "../matrix-utils";
import { UserMenuContainer } from "../UserMenuContainer";
import { Body, Link } from "../typography/Typography";
import { useLocationNavigation } from "../useLocationNavigation";
import { useMediaHandler } from "../settings/useMediaHandler";
import { VideoPreview } from "./VideoPreview";
import { AudioPreview } from "./AudioPreview";
import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview";
import { MediaDevicesState } from "./devices/useMediaDevices";
interface Props {
client: MatrixClient;
groupCall: GroupCall;
roomName: string;
avatarUrl: string;
state: GroupCallState;
onInitLocalCallFeed: () => void;
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
localMedia: LocalMediaInfo;
onEnter: (e: PressEvent) => void;
localCallFeed: CallFeed;
microphoneMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
localVideoMuted: boolean;
roomIdOrAlias: string;
isEmbedded: boolean;
hideHeader: boolean;
}
export function LobbyView({
client,
groupCall,
roomName,
avatarUrl,
state,
onInitLocalCallFeed,
onEnter,
localCallFeed,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
roomIdOrAlias,
isEmbedded,
hideHeader,
}: Props) {
export function LobbyView(props: Props) {
const { t } = useTranslation();
const { stream } = useCallFeed(localCallFeed);
const {
audioInput,
audioInputs,
setAudioInput,
audioOutput,
audioOutputs,
setAudioOutput,
} = useMediaHandler();
useLocationNavigation();
useEffect(() => {
onInitLocalCallFeed();
}, [onInitLocalCallFeed]);
useLocationNavigation(state === GroupCallState.InitializingLocalCallFeed);
const joinCallButtonRef = useRef<HTMLButtonElement>();
useEffect(() => {
if (state === GroupCallState.LocalCallFeedInitialized) {
const joinCallButtonRef = React.useRef<HTMLButtonElement>();
React.useEffect(() => {
if (joinCallButtonRef.current) {
joinCallButtonRef.current.focus();
}
}, [state]);
}, [joinCallButtonRef]);
return (
<div className={styles.room}>
{!hideHeader && (
{!props.hideHeader && (
<Header>
<LeftNav>
<RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
<RoomHeaderInfo
roomName={props.matrixInfo.roomName}
avatarUrl={props.matrixInfo.avatarUrl}
/>
</LeftNav>
<RightNav>
<UserMenuContainer />
@ -106,44 +66,24 @@ export function LobbyView({
)}
<div className={styles.joinRoom}>
<div className={styles.joinRoomContent}>
{groupCall.isPtt ? (
<AudioPreview
roomName={roomName}
state={state}
audioInput={audioInput}
audioInputs={audioInputs}
setAudioInput={setAudioInput}
audioOutput={audioOutput}
audioOutputs={audioOutputs}
setAudioOutput={setAudioOutput}
/>
) : (
<VideoPreview
state={state}
client={client}
roomIdOrAlias={roomIdOrAlias}
microphoneMuted={microphoneMuted}
localVideoMuted={localVideoMuted}
toggleLocalVideoMuted={toggleLocalVideoMuted}
toggleMicrophoneMuted={toggleMicrophoneMuted}
stream={stream}
audioOutput={audioOutput}
matrixInfo={props.matrixInfo}
mediaDevices={props.mediaDevices}
localMediaInfo={props.localMedia}
/>
)}
<Trans>
<Button
ref={joinCallButtonRef}
className={styles.copyButton}
size="lg"
disabled={state !== GroupCallState.LocalCallFeedInitialized}
onPress={onEnter}
onPress={props.onEnter}
>
Join call now
</Button>
<Body>Or</Body>
<CopyButton
variant="secondaryCopy"
value={getRoomUrl(roomIdOrAlias)}
value={getRoomUrl(props.matrixInfo.roomName)}
className={styles.copyButton}
copiedMessage={t("Call link copied")}
>
@ -151,7 +91,7 @@ export function LobbyView({
</CopyButton>
</Trans>
</div>
{!isEmbedded && (
{!props.isEmbedded && (
<Body className={styles.joinRoomFooter}>
<Link color="primary" to="/">
{t("Take me Home")}

View file

@ -16,7 +16,6 @@ limitations under the License.
import React, { useCallback } from "react";
import { Item } from "@react-stately/collections";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { OverlayTriggerState } from "@react-stately/overlays";
import { useTranslation } from "react-i18next";
@ -33,11 +32,13 @@ import { InviteModal } from "./InviteModal";
import { TooltipTrigger } from "../Tooltip";
import { FeedbackModal } from "./FeedbackModal";
import { Config } from "../config/Config";
import { MediaDevicesState } from "./devices/useMediaDevices";
interface Props {
roomIdOrAlias: string;
roomId: string;
mediaDevices: MediaDevicesState;
inCall: boolean;
groupCall: GroupCall;
showInvite: boolean;
feedbackModalState: OverlayTriggerState;
feedbackModalProps: {
@ -46,16 +47,8 @@ interface Props {
};
}
export function OverflowMenu({
roomIdOrAlias,
inCall,
groupCall,
showInvite,
feedbackModalState,
feedbackModalProps,
}: Props) {
export function OverflowMenu(props: Props) {
const { t } = useTranslation();
const {
modalState: inviteModalState,
modalProps: inviteModalProps,
@ -89,11 +82,11 @@ export function OverflowMenu({
settingsModalState.open();
break;
case "feedback":
feedbackModalState.open();
props.feedbackModalState.open();
break;
}
},
[feedbackModalState, inviteModalState, settingsModalState]
[props.feedbackModalState, inviteModalState, settingsModalState]
);
const tooltip = useCallback(() => t("More"), [t]);
@ -106,9 +99,9 @@ export function OverflowMenu({
<OverflowIcon />
</Button>
</TooltipTrigger>
{(props: JSX.IntrinsicAttributes) => (
<Menu {...props} label={t("More menu")} onAction={onAction}>
{showInvite && (
{(attr: JSX.IntrinsicAttributes) => (
<Menu {...attr} label={t("More menu")} onAction={onAction}>
{props.showInvite && (
<Item key="invite" textValue={t("Invite people")}>
<AddUserIcon />
<span>{t("Invite people")}</span>
@ -127,15 +120,20 @@ export function OverflowMenu({
</Menu>
)}
</PopoverMenuTrigger>
{settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
{settingsModalState.isOpen && (
<SettingsModal
mediaDevices={props.mediaDevices}
{...settingsModalProps}
/>
)}
{feedbackModalState.isOpen && (
{inviteModalState.isOpen && (
<InviteModal roomIdOrAlias={props.roomId} {...inviteModalProps} />
)}
{props.feedbackModalState.isOpen && (
<FeedbackModal
{...feedbackModalProps}
roomId={groupCall?.room.roomId}
inCall={inCall}
roomId={props.roomId}
inCall={props.inCall}
{...props.feedbackModalProps}
/>
)}
</>

View file

@ -1,56 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.pttButton {
width: 100vw;
aspect-ratio: 1;
max-height: min(232px, calc(100vh - 16px));
max-width: min(232px, calc(100vw - 16px));
border-radius: 116px;
color: var(--primary-content);
border: 6px solid var(--accent);
background-color: #21262c;
position: relative;
padding: 0;
margin: 4px;
cursor: pointer;
}
.micIcon {
max-height: 50%;
}
.avatar {
/* Remove explicit size to allow avatar to scale with the button */
width: 100% !important;
height: 100% !important;
}
.talking {
background-color: var(--accent);
cursor: unset;
}
.networkWaiting {
background-color: var(--tertiary-content);
border-color: var(--tertiary-content);
cursor: unset;
}
.error {
background-color: var(--alert);
border-color: var(--alert);
}

View file

@ -1,247 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useState, useRef } from "react";
import classNames from "classnames";
import { useSpring, animated } from "@react-spring/web";
import { logger } from "@sentry/utils";
import styles from "./PTTButton.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { useEventTarget } from "../useEvents";
import { Avatar } from "../Avatar";
import { usePrefersReducedMotion } from "../usePrefersReducedMotion";
import { getSetting } from "../settings/useSetting";
interface Props {
enabled: boolean;
showTalkOverError: boolean;
activeSpeakerUserId: string;
activeSpeakerDisplayName: string;
activeSpeakerAvatarUrl: string;
activeSpeakerIsLocalUser: boolean;
activeSpeakerVolume: number;
size: number;
startTalking: () => void;
stopTalking: () => void;
networkWaiting: boolean;
enqueueNetworkWaiting: (value: boolean, delay: number) => void;
setNetworkWaiting: (value: boolean) => void;
}
export const PTTButton: React.FC<Props> = ({
enabled,
showTalkOverError,
activeSpeakerUserId,
activeSpeakerDisplayName,
activeSpeakerAvatarUrl,
activeSpeakerIsLocalUser,
activeSpeakerVolume,
size,
startTalking,
stopTalking,
networkWaiting,
enqueueNetworkWaiting,
setNetworkWaiting,
}) => {
const buttonRef = useRef<HTMLButtonElement>();
const [activeTouchId, setActiveTouchId] = useState<number | null>(null);
const [buttonHeld, setButtonHeld] = useState(false);
const hold = useCallback(() => {
// This update is delayed so the user only sees it if latency is significant
if (buttonHeld) return;
setButtonHeld(true);
enqueueNetworkWaiting(true, 100);
startTalking();
}, [enqueueNetworkWaiting, startTalking, buttonHeld]);
const unhold = useCallback(() => {
if (!buttonHeld) return;
setButtonHeld(false);
setNetworkWaiting(false);
stopTalking();
}, [setNetworkWaiting, stopTalking, buttonHeld]);
const onMouseUp = useCallback(() => {
logger.info("Mouse up event: unholding PTT button");
unhold();
}, [unhold]);
const onBlur = useCallback(() => {
logger.info("Blur event: unholding PTT button");
unhold();
}, [unhold]);
const onButtonMouseDown = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
hold();
},
[hold]
);
// These listeners go on the window so even if the user's cursor / finger
// leaves the button while holding it, the button stays pushed until
// they stop clicking / tapping.
useEventTarget(window, "mouseup", onMouseUp);
useEventTarget(
window,
"touchend",
useCallback(
(e: TouchEvent) => {
// ignore any ended touches that weren't the one pressing the
// button (bafflingly the TouchList isn't an iterable so we
// have to do this a really old-school way).
let touchFound = false;
for (let i = 0; i < e.changedTouches.length; ++i) {
if (e.changedTouches.item(i).identifier === activeTouchId) {
touchFound = true;
break;
}
}
if (!touchFound) return;
logger.info("Touch event ended: unholding PTT button");
e.preventDefault();
unhold();
setActiveTouchId(null);
},
[unhold, activeTouchId, setActiveTouchId]
)
);
// This is a native DOM listener too because we want to preventDefault in it
// to stop also getting a click event, so we need it to be non-passive.
useEventTarget(
buttonRef.current,
"touchstart",
useCallback(
(e: TouchEvent) => {
e.preventDefault();
hold();
setActiveTouchId(e.changedTouches.item(0).identifier);
},
[hold, setActiveTouchId]
),
{ passive: false }
);
useEventTarget(
window,
"keydown",
useCallback(
(e: KeyboardEvent) => {
if (e.code === "Space") {
if (!enabled) return;
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
e.preventDefault();
hold();
}
},
[enabled, hold]
)
);
useEventTarget(
window,
"keyup",
useCallback(
(e: KeyboardEvent) => {
if (e.code === "Space") {
// Check if keyboard shortcuts are enabled
const keyboardShortcuts = getSetting("keyboard-shortcuts", true);
if (!keyboardShortcuts) {
return;
}
e.preventDefault();
logger.info("Keyup event for spacebar: unholding PTT button");
unhold();
}
},
[unhold]
)
);
// TODO: We will need to disable this for a global PTT hotkey to work
useEventTarget(window, "blur", onBlur);
const prefersReducedMotion = usePrefersReducedMotion();
const { shadow } = useSpring({
immediate: prefersReducedMotion,
shadow: prefersReducedMotion
? activeSpeakerUserId
? 17
: 0
: (Math.max(activeSpeakerVolume, -70) + 70) * 0.6,
config: {
clamp: true,
tension: 300,
},
});
const shadowColor = showTalkOverError
? "var(--alert-20)"
: networkWaiting
? "var(--tertiary-content-20)"
: "var(--accent-20)";
return (
<animated.button
className={classNames(styles.pttButton, {
[styles.talking]: activeSpeakerUserId,
[styles.networkWaiting]: networkWaiting,
[styles.error]: showTalkOverError,
})}
style={{
boxShadow: shadow.to(
(s) =>
`0px 0px 0px ${s}px ${shadowColor}, 0px 0px 0px ${
2 * s
}px ${shadowColor}`
),
}}
onMouseDown={onButtonMouseDown}
ref={buttonRef}
>
{activeSpeakerIsLocalUser || !activeSpeakerUserId ? (
<MicIcon
className={styles.micIcon}
width={size / 3}
height={size / 3}
/>
) : (
<Avatar
key={activeSpeakerUserId}
size={size - 12}
src={activeSpeakerAvatarUrl}
fallback={activeSpeakerDisplayName.slice(0, 1).toUpperCase()}
className={styles.avatar}
/>
)}
</animated.button>
);
};

View file

@ -1,130 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.pttCallView {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
min-height: 100%;
position: fixed;
height: 100%;
width: 100%;
}
@media (hover: none) {
.pttCallView {
user-select: none;
}
}
.center {
width: 100%;
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
}
.participants {
display: flex;
flex-direction: column;
margin: 20px;
text-align: center;
}
.participants > p {
color: var(--secondary-content);
margin-bottom: 8px;
}
.facepile {
align-self: center;
}
.talkingInfo {
display: flex;
flex-shrink: 0;
flex-direction: column;
align-items: center;
margin-bottom: 20px;
height: 88px;
}
.speakerIcon {
margin-right: 8px;
}
.pttButtonContainer {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
justify-content: center;
}
.actionTip {
margin-top: 20px;
margin-bottom: 20px;
font-size: var(--font-size-subtitle);
}
.footer {
position: relative;
display: flex;
justify-content: center;
height: 64px;
margin-bottom: 20px;
}
.footer > * {
margin-right: 30px;
}
.footer > :last-child {
margin-right: 0px;
}
@media (min-width: 800px) {
.participants {
margin-bottom: 67px;
}
.talkingInfo {
margin-bottom: 38px;
}
.center {
margin-top: 48px;
}
.actionTip {
margin-top: 42px;
margin-bottom: 45px;
}
.pttButtonContainer {
flex: 0;
margin-bottom: 0;
justify-content: flex-start;
}
.footer {
flex: auto;
order: 4;
}
}

View file

@ -1,316 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useEffect, useMemo } from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import i18n from "i18next";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import { useTranslation } from "react-i18next";
import { useDelayedState } from "../useDelayedState";
import { useModalTriggerState } from "../Modal";
import { InviteModal } from "./InviteModal";
import { HangupButton, InviteButton } from "../button";
import { Header, LeftNav, RightNav, RoomSetupHeaderInfo } from "../Header";
import styles from "./PTTCallView.module.css";
import { Facepile } from "../Facepile";
import { PTTButton } from "./PTTButton";
import { PTTFeed } from "./PTTFeed";
import { useMediaHandler } from "../settings/useMediaHandler";
import { usePTT } from "./usePTT";
import { Timer } from "./Timer";
import { Toggle } from "../input/Toggle";
import { ReactComponent as AudioIcon } from "../icons/Audio.svg";
import { usePTTSounds } from "../sound/usePttSounds";
import { PTTClips } from "../sound/PTTClips";
import { GroupCallInspector } from "./GroupCallInspector";
import { OverflowMenu } from "./OverflowMenu";
import { Size } from "../Avatar";
import { ParticipantInfo } from "./useGroupCall";
function getPromptText(
networkWaiting: boolean,
showTalkOverError: boolean,
pttButtonHeld: boolean,
activeSpeakerIsLocalUser: boolean,
talkOverEnabled: boolean,
activeSpeakerUserId: string,
activeSpeakerDisplayName: string,
connected: boolean,
t: typeof i18n.t
): string {
if (!connected) return t("Connection lost");
const isTouchScreen = Boolean(window.ontouchstart !== undefined);
if (networkWaiting) {
return t("Waiting for network");
}
if (showTalkOverError) {
return t("You can't talk at the same time");
}
if (pttButtonHeld && activeSpeakerIsLocalUser) {
if (isTouchScreen) {
return t("Release to stop");
} else {
return t("Release spacebar key to stop");
}
}
if (talkOverEnabled && activeSpeakerUserId && !activeSpeakerIsLocalUser) {
if (isTouchScreen) {
return t("Press and hold to talk over {{name}}", {
name: activeSpeakerDisplayName,
});
} else {
return t("Press and hold spacebar to talk over {{name}}", {
name: activeSpeakerDisplayName,
});
}
}
if (isTouchScreen) {
return t("Press and hold to talk");
} else {
return t("Press and hold spacebar to talk");
}
}
interface Props {
client: MatrixClient;
roomIdOrAlias: string;
roomName: string;
avatarUrl: string;
groupCall: GroupCall;
participants: Map<RoomMember, Map<string, ParticipantInfo>>;
userMediaFeeds: CallFeed[];
onLeave: () => void;
isEmbedded: boolean;
hideHeader: boolean;
}
export const PTTCallView: React.FC<Props> = ({
client,
roomIdOrAlias,
roomName,
avatarUrl,
groupCall,
participants,
userMediaFeeds,
onLeave,
isEmbedded,
hideHeader,
}) => {
const { t } = useTranslation();
const { modalState: inviteModalState, modalProps: inviteModalProps } =
useModalTriggerState();
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const [containerRef, bounds] = useMeasure({ polyfill: ResizeObserver });
const facepileSize = bounds.width < 800 ? Size.SM : Size.MD;
const showControls = bounds.height > 500;
const pttButtonSize = 232;
const { audioOutput } = useMediaHandler();
const {
startTalkingLocalRef,
startTalkingRemoteRef,
blockedRef,
endTalkingRef,
playClip,
} = usePTTSounds();
const {
pttButtonHeld,
isAdmin,
talkOverEnabled,
setTalkOverEnabled,
activeSpeakerUserId,
activeSpeakerVolume,
startTalking,
stopTalking,
transmitBlocked,
connected,
} = usePTT(client, groupCall, userMediaFeeds, playClip);
const participatingMembers = useMemo(() => {
const members: RoomMember[] = [];
for (const [member, deviceMap] of participants) {
// Repeat the member for as many devices as they're using
for (let i = 0; i < deviceMap.size; i++) members.push(member);
}
return members;
}, [participants]);
const [talkingExpected, enqueueTalkingExpected, setTalkingExpected] =
useDelayedState(false);
const showTalkOverError = pttButtonHeld && transmitBlocked;
const networkWaiting =
talkingExpected && !activeSpeakerUserId && !showTalkOverError;
const activeSpeakerIsLocalUser = activeSpeakerUserId === client.getUserId();
const activeSpeakerUser = activeSpeakerUserId
? client.getUser(activeSpeakerUserId)
: null;
const activeSpeakerAvatarUrl = activeSpeakerUser?.avatarUrl;
const activeSpeakerDisplayName = activeSpeakerUser
? activeSpeakerUser.displayName
: "";
useEffect(() => {
setTalkingExpected(activeSpeakerIsLocalUser);
}, [activeSpeakerIsLocalUser, setTalkingExpected]);
return (
<div className={styles.pttCallView} ref={containerRef}>
<PTTClips
startTalkingLocalRef={startTalkingLocalRef}
startTalkingRemoteRef={startTalkingRemoteRef}
endTalkingRef={endTalkingRef}
blockedRef={blockedRef}
/>
<GroupCallInspector
client={client}
groupCall={groupCall}
// Never shown in PTT mode, but must be present to collect call state
// https://github.com/vector-im/element-call/issues/328
show={false}
/>
{!hideHeader && showControls && (
<Header className={styles.header}>
<LeftNav>
<RoomSetupHeaderInfo
roomName={roomName}
avatarUrl={avatarUrl}
onPress={onLeave}
isEmbedded={isEmbedded}
/>
</LeftNav>
<RightNav />
</Header>
)}
<div className={styles.center}>
{/* Always render this because the window will become shorter when the on-screen
keyboard appears, so if we don't render it, the dialog will unmount. */}
<div style={{ display: showControls ? "block" : "none" }}>
<div className={styles.participants}>
<p>
{t("{{count}} people connected", {
count: participatingMembers.length,
})}
</p>
<Facepile
size={facepileSize}
max={8}
className={styles.facepile}
client={client}
members={participatingMembers}
/>
</div>
<div className={styles.footer}>
<OverflowMenu
inCall
roomIdOrAlias={roomIdOrAlias}
groupCall={groupCall}
showInvite={false}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
/>
{!isEmbedded && <HangupButton onPress={onLeave} />}
<InviteButton onPress={() => inviteModalState.open()} />
</div>
</div>
<div className={styles.pttButtonContainer}>
{showControls &&
(activeSpeakerUserId ? (
<div className={styles.talkingInfo}>
<h2>
{!activeSpeakerIsLocalUser && (
<AudioIcon className={styles.speakerIcon} />
)}
{activeSpeakerIsLocalUser
? t("Talking…")
: t("{{name}} is talking…", {
name: activeSpeakerDisplayName,
})}
</h2>
<Timer value={activeSpeakerUserId} />
</div>
) : (
<div className={styles.talkingInfo} />
))}
<PTTButton
enabled={!feedbackModalState.isOpen}
showTalkOverError={showTalkOverError}
activeSpeakerUserId={activeSpeakerUserId}
activeSpeakerDisplayName={activeSpeakerDisplayName}
activeSpeakerAvatarUrl={activeSpeakerAvatarUrl}
activeSpeakerIsLocalUser={activeSpeakerIsLocalUser}
activeSpeakerVolume={activeSpeakerVolume}
size={pttButtonSize}
startTalking={startTalking}
stopTalking={stopTalking}
networkWaiting={networkWaiting}
enqueueNetworkWaiting={enqueueTalkingExpected}
setNetworkWaiting={setTalkingExpected}
/>
{showControls && (
<p className={styles.actionTip}>
{getPromptText(
networkWaiting,
showTalkOverError,
pttButtonHeld,
activeSpeakerIsLocalUser,
talkOverEnabled,
activeSpeakerUserId,
activeSpeakerDisplayName,
connected,
t
)}
</p>
)}
{userMediaFeeds.map((callFeed) => (
<PTTFeed
key={callFeed.userId}
callFeed={callFeed}
audioOutputDevice={audioOutput}
/>
))}
{isAdmin && showControls && (
<Toggle
isSelected={talkOverEnabled}
onChange={setTalkOverEnabled}
label={t("Talk over speaker")}
id="talkOverEnabled"
/>
)}
</div>
</div>
{inviteModalState.isOpen && showControls && (
<InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
)}
</div>
);
};

View file

@ -1,19 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.audioFeed {
display: none;
}

View file

@ -1,34 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import React from "react";
import { useCallFeed } from "../video-grid/useCallFeed";
import { useMediaStream } from "../video-grid/useMediaStream";
import styles from "./PTTFeed.module.css";
export function PTTFeed({
callFeed,
audioOutputDevice,
}: {
callFeed: CallFeed;
audioOutputDevice: string;
}) {
const { isLocal, stream } = useCallFeed(callFeed);
const mediaRef = useMediaStream(stream, audioOutputDevice, isLocal);
return <audio ref={mediaRef} className={styles.audioFeed} playsInline />;
}

View file

@ -24,7 +24,6 @@ import { RoomAuthView } from "./RoomAuthView";
import { GroupCallLoader } from "./GroupCallLoader";
import { GroupCallView } from "./GroupCallView";
import { useUrlParams } from "../UrlParams";
import { MediaHandlerProvider } from "../settings/useMediaHandler";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { translatedError } from "../TranslatedError";
@ -94,7 +93,6 @@ export const RoomPage: FC = () => {
}
return (
<MediaHandlerProvider client={client}>
<GroupCallLoader
client={client}
roomIdOrAlias={roomIdOrAlias}
@ -103,6 +101,5 @@ export const RoomPage: FC = () => {
>
{groupCallView}
</GroupCallLoader>
</MediaHandlerProvider>
);
};

View file

@ -17,95 +17,98 @@ limitations under the License.
import React from "react";
import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer";
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { useTranslation } from "react-i18next";
import { Track } from "livekit-client";
import { MicButton, VideoButton } from "../button";
import { useMediaStream } from "../video-grid/useMediaStream";
import { OverflowMenu } from "./OverflowMenu";
import { Avatar } from "../Avatar";
import { useProfile } from "../profile/useProfile";
import styles from "./VideoPreview.module.css";
import { Body } from "../typography/Typography";
import { useModalTriggerState } from "../Modal";
import { MediaDevicesState } from "./devices/useMediaDevices";
export type MatrixInfo = {
userName: string;
avatarUrl: string;
roomName: string;
roomId: string;
};
export type MediaInfo = {
track: Track; // TODO: Replace it by a more generic `CallFeed` type from JS SDK once we generalise the types.
muted: boolean;
setMuted: (muted: boolean) => void;
};
export type LocalMediaInfo = {
audio?: MediaInfo;
video?: MediaInfo;
};
interface Props {
client: MatrixClient;
state: GroupCallState;
roomIdOrAlias: string;
microphoneMuted: boolean;
localVideoMuted: boolean;
toggleLocalVideoMuted: () => void;
toggleMicrophoneMuted: () => void;
audioOutput: string;
stream: MediaStream;
matrixInfo: MatrixInfo;
mediaDevices: MediaDevicesState;
localMediaInfo: LocalMediaInfo;
}
export function VideoPreview({
client,
state,
roomIdOrAlias,
microphoneMuted,
localVideoMuted,
toggleLocalVideoMuted,
toggleMicrophoneMuted,
audioOutput,
stream,
matrixInfo,
mediaDevices,
localMediaInfo,
}: Props) {
const { t } = useTranslation();
const videoRef = useMediaStream(stream, audioOutput, true);
const { displayName, avatarUrl } = useProfile(client);
const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
const avatarSize = (previewBounds.height - 66) / 2;
const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
useModalTriggerState();
const mediaElement = React.useRef(null);
React.useEffect(() => {
if (mediaElement.current) {
localMediaInfo.video?.track.attach(mediaElement.current);
}
return () => {
localMediaInfo.video?.track.detach();
};
}, [localMediaInfo]);
return (
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
{t("Camera/microphone permissions needed to join the call.")}
</Body>
)}
{state === GroupCallState.InitializingLocalCallFeed && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
{t("Accept camera/microphone permissions to join the call.")}
</Body>
)}
{state === GroupCallState.LocalCallFeedInitialized && (
<video ref={mediaElement} muted playsInline disablePictureInPicture />
<>
{localVideoMuted && (
{(localMediaInfo.video?.muted ?? true) && (
<div className={styles.avatarContainer}>
<Avatar
size={avatarSize}
src={avatarUrl}
fallback={displayName.slice(0, 1).toUpperCase()}
size={(previewBounds.height - 66) / 2}
src={matrixInfo.avatarUrl}
fallback={matrixInfo.userName.slice(0, 1).toUpperCase()}
/>
</div>
)}
<div className={styles.previewButtons}>
{localMediaInfo.audio && (
<MicButton
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
muted={localMediaInfo.audio?.muted}
onPress={() =>
localMediaInfo.audio?.setMuted(!localMediaInfo.audio?.muted)
}
/>
)}
{localMediaInfo.video && (
<VideoButton
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
muted={localMediaInfo.video?.muted}
onPress={() =>
localMediaInfo.video?.setMuted(!localMediaInfo.video?.muted)
}
/>
)}
<OverflowMenu
roomIdOrAlias={roomIdOrAlias}
roomId={matrixInfo.roomId}
mediaDevices={mediaDevices}
feedbackModalState={feedbackModalState}
feedbackModalProps={feedbackModalProps}
inCall={false}
groupCall={undefined}
showInvite={false}
/>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,18 @@
import type TypedEmitter from "typed-emitter";
/* This file should become a part of LiveKit JS SDK. */
// Generic interface for all types that are capable of providing and managing media devices.
export interface MediaDevicesManager
extends TypedEmitter<MediaDeviceHandlerCallbacks> {
getDevices(kind: MediaDeviceKind): Promise<MediaDeviceInfo[]>;
setActiveDevice(kind: MediaDeviceKind, deviceId: string): Promise<void>;
}
export type MediaDeviceHandlerCallbacks = {
devicesChanged: () => Promise<void>;
};
export enum MediaDeviceHandlerEvents {
DevicesChanged = "devicesChanged",
}

View file

@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { MediaDevicesManager } from "./mediaDevices";
export type MediaDevices = {
available: MediaDeviceInfo[];
selected: number;
};
export type MediaDevicesState = {
state: Map<MediaDeviceKind, MediaDevices>;
selectActiveDevice: (
kind: MediaDeviceKind,
deviceId: string
) => Promise<void>;
};
export function useMediaDevices(
mediaDeviceHandler: MediaDevicesManager
): MediaDevicesState {
// Create a React state to store the available devices and the selected device for each kind.
const [state, setState] = useState<Map<MediaDeviceKind, MediaDevices>>(
new Map()
);
// Update the React state when the available devices change.
useEffect(() => {
// Define a callback that is going to be called each time the available devices change.
const updateDevices = async () => {
const mediaDeviceKinds: MediaDeviceKind[] = [
"audioinput",
"audiooutput",
"videoinput",
];
const newState = new Map(state);
// Request all the available devices for each kind.
for (const kind of mediaDeviceKinds) {
const devices = await mediaDeviceHandler.getDevices(
kind as MediaDeviceKind
);
// If newly requested devices are empty, remove the kind from the React state.
if (devices.length === 0) {
newState.delete(kind);
continue;
}
// Otherwise, check if the current state contains any selected device and find this device in the new list of devices.
// If the device exists, update the React state with the new list of devices and the index of the selected device.
// If the device does not exist, select the first one (default device).
const selectedDevice = state.get(kind);
const newSelectedDeviceIndex = selectedDevice
? devices.findIndex(
(device) =>
device.deviceId ===
selectedDevice.available[selectedDevice.selected].deviceId
)
: 0;
newState.set(kind, {
available: devices,
selected: newSelectedDeviceIndex !== -1 ? newSelectedDeviceIndex : 0,
});
}
if (devicesChanged(state, newState)) {
setState(newState);
}
};
updateDevices();
mediaDeviceHandler.on("devicesChanged", updateDevices);
return () => {
mediaDeviceHandler.off("devicesChanged", updateDevices);
};
}, [mediaDeviceHandler, state]);
const selectActiveDeviceFunc = async (
kind: MediaDeviceKind,
deviceId: string
) => {
await mediaDeviceHandler.setActiveDevice(kind, deviceId);
// Update react state as well.
setState((prevState) => {
const newState = new Map(prevState);
const devices = newState.get(kind);
if (!devices) {
return newState;
}
const newSelectedDeviceIndex = devices.available.findIndex(
(device) => device.deviceId === deviceId
);
newState.set(kind, {
available: devices.available,
selected: newSelectedDeviceIndex,
});
return newState;
});
};
const [selectActiveDevice] = useState<
(kind: MediaDeviceKind, deviceId: string) => Promise<void>
>(selectActiveDeviceFunc);
return {
state,
selectActiveDevice,
};
}
// Determine if any devices changed between the old and new state.
function devicesChanged(
map1: Map<MediaDeviceKind, MediaDevices>,
map2: Map<MediaDeviceKind, MediaDevices>
): boolean {
if (map1.size !== map2.size) {
return true;
}
for (const [key, value] of map1) {
const newValue = map2.get(key);
if (!newValue) {
return true;
}
if (value.selected !== newValue.selected) {
return true;
}
if (value.available.length !== newValue.available.length) {
return true;
}
for (let i = 0; i < value.available.length; i++) {
if (value.available[i].deviceId !== newValue.available[i].deviceId) {
return true;
}
}
}
return false;
}

145
src/room/useLiveKit.ts Normal file
View file

@ -0,0 +1,145 @@
import { EventEmitter } from "events";
import { Room, RoomEvent, Track } from "livekit-client";
import React from "react";
import { useLocalParticipant } from "@livekit/components-react";
import {
MediaDeviceHandlerCallbacks,
MediaDeviceHandlerEvents,
MediaDevicesManager,
} from "./devices/mediaDevices";
import { MediaDevicesState, useMediaDevices } from "./devices/useMediaDevices";
import { LocalMediaInfo, MediaInfo } from "./VideoPreview";
import type TypedEmitter from "typed-emitter";
type LiveKitState = {
mediaDevices: MediaDevicesState;
localMedia: LocalMediaInfo;
enterRoom: () => Promise<void>;
leaveRoom: () => Promise<void>;
};
// Returns the React state for the LiveKit's Room class.
// The actual return type should be `LiveKitState`, but since this is a React hook, the initialisation is
// delayed (done after the rendering, not during the rendering), because of that this function may return `undefined`.
// But soon this state is changed to the actual `LiveKitState` value.
export function useLiveKit(
url: string,
token: string
): LiveKitState | undefined {
// TODO: Pass the proper paramters to configure the room (supported codecs, simulcast, adaptive streaming, etc).
const [room] = React.useState<Room>(() => {
return new Room();
});
const [mediaDevicesManager] = React.useState<MediaDevicesManager>(() => {
return new LkMediaDevicesManager(room);
});
const { state: mediaDevicesState, selectActiveDevice: selectDeviceFn } =
useMediaDevices(mediaDevicesManager);
React.useEffect(() => {
console.log("media devices changed, mediaDevices:", mediaDevicesState);
}, [mediaDevicesState]);
const {
microphoneTrack,
isMicrophoneEnabled,
cameraTrack,
isCameraEnabled,
localParticipant,
} = useLocalParticipant({ room });
const [state, setState] = React.useState<LiveKitState | undefined>(undefined);
React.useEffect(() => {
// Helper to create local media without the
const createLocalMedia = (
enabled: boolean,
track: Track | undefined,
setEnabled
): MediaInfo | undefined => {
if (!track) {
return undefined;
}
return {
track,
muted: !enabled,
setMuted: async (newState: boolean) => {
if (enabled != newState) {
await setEnabled(newState);
}
},
};
};
const state: LiveKitState = {
mediaDevices: {
state: mediaDevicesState,
selectActiveDevice: selectDeviceFn,
},
localMedia: {
audio: createLocalMedia(
isMicrophoneEnabled,
microphoneTrack?.track,
localParticipant.setMicrophoneEnabled
),
video: createLocalMedia(
isCameraEnabled,
cameraTrack?.track,
localParticipant.setCameraEnabled
),
},
enterRoom: async () => {
// TODO: Pass connection parameters (autosubscribe, etc.).
await room.connect(url, token);
},
leaveRoom: async () => {
await room.disconnect();
},
};
setState(state);
}, [
url,
token,
room,
mediaDevicesState,
selectDeviceFn,
localParticipant,
microphoneTrack,
cameraTrack,
isMicrophoneEnabled,
isCameraEnabled,
]);
return state;
}
// Implement the MediaDevicesHandler interface for the LiveKit's Room class by wrapping it, so that
// we can pass the confined version of the `Room` to the `MediaDevicesHandler` consumers.
export class LkMediaDevicesManager
extends (EventEmitter as new () => TypedEmitter<MediaDeviceHandlerCallbacks>)
implements MediaDevicesManager
{
private room: Room;
constructor(room: Room) {
super();
this.room = room;
this.room.on(RoomEvent.MediaDevicesChanged, () => {
this.emit(MediaDeviceHandlerEvents.DevicesChanged);
});
}
async getDevices(kind: MediaDeviceKind) {
return await Room.getLocalDevices(kind);
}
async setActiveDevice(kind: MediaDeviceKind, deviceId: string) {
await this.room.switchActiveDevice(kind, deviceId);
}
}

View file

@ -26,7 +26,7 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
import { SelectInput } from "../input/SelectInput";
import { useMediaHandler } from "./useMediaHandler";
import { MediaDevicesState } from "../room/devices/useMediaDevices";
import {
useKeyboardShortcuts,
useSpatialAudio,
@ -40,23 +40,13 @@ import { useDownloadDebugLog } from "./submit-rageshake";
import { Body } from "../typography/Typography";
interface Props {
mediaDevices: MediaDevicesState;
isOpen: boolean;
onClose: () => void;
}
export const SettingsModal = (props: Props) => {
const { t } = useTranslation();
const {
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
} = useMediaHandler();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
@ -65,6 +55,30 @@ export const SettingsModal = (props: Props) => {
const downloadDebugLog = useDownloadDebugLog();
// Generate a `SelectInput` with a list of devices for a given device kind.
const generateDeviceSelection = (kind: MediaDeviceKind, caption: string) => {
const devices = props.mediaDevices.state.get(kind);
if (!devices) return null;
return (
<SelectInput
label={caption}
selectedKey={devices.available[devices.selected].deviceId}
onSelectionChange={(id) =>
props.mediaDevices.selectActiveDevice(kind, id.toString())
}
>
{devices.available.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: `${caption} ${index + 1}`}
</Item>
))}
</SelectInput>
);
};
return (
<Modal
title={t("Settings")}
@ -82,34 +96,8 @@ export const SettingsModal = (props: Props) => {
</>
}
>
<SelectInput
label={t("Microphone")}
selectedKey={audioInput}
onSelectionChange={setAudioInput}
>
{audioInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Microphone {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
{audioOutputs.length > 0 && (
<SelectInput
label={t("Speaker")}
selectedKey={audioOutput}
onSelectionChange={setAudioOutput}
>
{audioOutputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Speaker {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
)}
{generateDeviceSelection("audioinput", t("Microphone"))}
{generateDeviceSelection("audiooutput", t("Speaker"))}
<FieldRow>
<InputField
id="spatialAudio"
@ -138,19 +126,7 @@ export const SettingsModal = (props: Props) => {
</>
}
>
<SelectInput
label={t("Camera")}
selectedKey={videoInput}
onSelectionChange={setVideoInput}
>
{videoInputs.map(({ deviceId, label }, index) => (
<Item key={deviceId}>
{!!label && label.trim().length > 0
? label
: t("Camera {{n}}", { n: index + 1 })}
</Item>
))}
</SelectInput>
{generateDeviceSelection("videoinput", t("Camera"))}
</TabItem>
<TabItem
title={

View file

@ -1,271 +0,0 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import React, {
useState,
useEffect,
useCallback,
useMemo,
useContext,
createContext,
ReactNode,
} from "react";
export interface MediaHandlerContextInterface {
audioInput: string;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
videoInput: string;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
audioOutput: string;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
}
const MediaHandlerContext =
createContext<MediaHandlerContextInterface>(undefined);
interface MediaPreferences {
audioInput?: string;
videoInput?: string;
audioOutput?: string;
}
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
if (mediaPreferences) {
try {
return JSON.parse(mediaPreferences);
} catch (e) {
return undefined;
}
} else {
return undefined;
}
}
function updateMediaPreferences(newPreferences: MediaPreferences): void {
const oldPreferences = getMediaPreferences();
localStorage.setItem(
"matrix-media-preferences",
JSON.stringify({
...oldPreferences,
...newPreferences,
})
);
}
interface Props {
client: MatrixClient;
children: ReactNode;
}
export function MediaHandlerProvider({ client, children }: Props): JSX.Element {
const [
{
audioInput,
videoInput,
audioInputs,
videoInputs,
audioOutput,
audioOutputs,
},
setState,
] = useState(() => {
const mediaPreferences = getMediaPreferences();
const mediaHandler = client.getMediaHandler();
mediaHandler.restoreMediaSettings(
mediaPreferences?.audioInput,
mediaPreferences?.videoInput
);
return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler.videoInput,
audioOutput: undefined,
audioInputs: [],
videoInputs: [],
audioOutputs: [],
};
});
useEffect(() => {
const mediaHandler = client.getMediaHandler();
function updateDevices(): void {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const mediaPreferences = getMediaPreferences();
const audioInputs = devices.filter(
(device) => device.kind === "audioinput"
);
const audioConnected = audioInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.audioInput
);
// @ts-ignore
let audioInput = mediaHandler.audioInput;
if (!audioConnected && audioInputs.length > 0) {
audioInput = audioInputs[0].deviceId;
}
const videoInputs = devices.filter(
(device) => device.kind === "videoinput"
);
const videoConnected = videoInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.videoInput
);
// @ts-ignore
let videoInput = mediaHandler.videoInput;
if (!videoConnected && videoInputs.length > 0) {
videoInput = videoInputs[0].deviceId;
}
const audioOutputs = devices.filter(
(device) => device.kind === "audiooutput"
);
let audioOutput = undefined;
if (
mediaPreferences &&
audioOutputs.some(
(device) => device.deviceId === mediaPreferences.audioOutput
)
) {
audioOutput = mediaPreferences.audioOutput;
}
if (
// @ts-ignore
(mediaHandler.videoInput && mediaHandler.videoInput !== videoInput) ||
// @ts-ignore
mediaHandler.audioInput !== audioInput
) {
mediaHandler.setMediaInputs(audioInput, videoInput);
}
updateMediaPreferences({ audioInput, videoInput, audioOutput });
setState({
audioInput,
videoInput,
audioOutput,
audioInputs,
videoInputs,
audioOutputs,
});
});
}
updateDevices();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
return () => {
mediaHandler.removeListener(
MediaHandlerEvent.LocalStreamsChanged,
updateDevices
);
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
mediaHandler.stopAllStreams();
};
}, [client]);
const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
client.getMediaHandler().setAudioInput(deviceId);
},
[client]
);
const setVideoInput: (deviceId: string) => void = useCallback(
(deviceId) => {
updateMediaPreferences({ videoInput: deviceId });
setState((prevState) => ({ ...prevState, videoInput: deviceId }));
client.getMediaHandler().setVideoInput(deviceId);
},
[client]
);
const setAudioOutput: (deviceId: string) => void = useCallback((deviceId) => {
updateMediaPreferences({ audioOutput: deviceId });
setState((prevState) => ({ ...prevState, audioOutput: deviceId }));
}, []);
const context: MediaHandlerContextInterface =
useMemo<MediaHandlerContextInterface>(
() => ({
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
}),
[
audioInput,
audioInputs,
setAudioInput,
videoInput,
videoInputs,
setVideoInput,
audioOutput,
audioOutputs,
setAudioOutput,
]
);
return (
<MediaHandlerContext.Provider value={context}>
{children}
</MediaHandlerContext.Provider>
);
}
export function useMediaHandler() {
return useContext(MediaHandlerContext);
}

View file

@ -1429,6 +1429,18 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@floating-ui/core@^1.2.6":
version "1.2.6"
resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.2.6.tgz#d21ace437cc919cdd8f1640302fa8851e65e75c0"
integrity sha512-EvYTiXet5XqweYGClEmpu3BoxmsQ4hkj3QaYA6qEnigCWffTP3vNRwBReTdrwDwo7OoJ3wM8Uoe9Uk4n+d4hfg==
"@floating-ui/dom@^1.1.0":
version "1.2.8"
resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.2.8.tgz#aee0f6ccc0787ab8fe741487a6e5e95b7b125375"
integrity sha512-XLwhYV90MxiHDq6S0rzFZj00fnDM+A1R9jhSioZoMsa7G0Q0i+Q4x40ajR8FHSdYDE1bgjG45mIWe6jtv9UPmg==
dependencies:
"@floating-ui/core" "^1.2.6"
"@formatjs/ecma402-abstract@1.11.4":
version "1.11.4"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz#b962dfc4ae84361f9f08fbce411b4e4340930eda"
@ -1821,6 +1833,26 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
"@livekit/components-core@0.6.7":
version "0.6.7"
resolved "https://registry.yarnpkg.com/@livekit/components-core/-/components-core-0.6.7.tgz#e6fdbdf0feade66f6c187dc8b7f1b54e2fbe4b85"
integrity sha512-Nc+HMvIhMRuZUYkUWxHobVH+ZpQNSwzdeVZpWOVea0hUGh7A3WeOY5rS0LY3zrvCAseRooOK+pQHna9KSFf2RQ==
dependencies:
"@floating-ui/dom" "^1.1.0"
email-regex "^5.0.0"
global-tld-list "^0.0.1093"
loglevel "^1.8.1"
rxjs "^7.8.0"
"@livekit/components-react@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@livekit/components-react/-/components-react-1.0.3.tgz#03a32c200fae6a386cdcaaab77226abab00c8673"
integrity sha512-HJxsEdApjQa5fa/qXXkixw2V6MRziWHKow7oRi1ZPsmxt/Xls9vbbsMFaUYPh6bXiBm8Fz4RznmdvMOPk1YIPg==
dependencies:
"@livekit/components-core" "0.6.7"
"@react-hook/latest" "^1.0.3"
clsx "^1.2.1"
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3":
version "0.1.0-alpha.4"
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz#1b20294e0354c3dcc9c7dc810d883198a4042f04"
@ -2185,6 +2217,11 @@
"@react-aria/utils" "^3.13.1"
clsx "^1.1.1"
"@react-hook/latest@^1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@react-hook/latest/-/latest-1.0.3.tgz#c2d1d0b0af8b69ec6e2b3a2412ba0768ac82db80"
integrity sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==
"@react-spring/animated@~9.4.5":
version "9.4.5"
resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.5.tgz#dd9921c716a4f4a3ed29491e0c0c9f8ca0eb1a54"
@ -5470,7 +5507,7 @@ cloneable-readable@^1.0.0:
process-nextick-args "^2.0.0"
readable-stream "^2.3.5"
clsx@^1.1.1:
clsx@^1.1.1, clsx@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
@ -6693,6 +6730,11 @@ elliptic@^6.5.3:
minimalistic-assert "^1.0.1"
minimalistic-crypto-utils "^1.0.1"
email-regex@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/email-regex/-/email-regex-5.0.0.tgz#c8b1f4c7f251929b53586a7a3891da09c8dea26d"
integrity sha512-he76Cm8JFxb6OGQHabLBPdsiStgPmJeAEhctmw0uhonUh1pCBsHpI6/rB62s2GNzjBb0YlhIcF/1l9Lp5AfH0Q==
emittery@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
@ -8194,6 +8236,11 @@ glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4, glob@^7.1.6, glob@^7.2.0:
once "^1.3.0"
path-is-absolute "^1.0.0"
global-tld-list@^0.0.1093:
version "0.0.1093"
resolved "https://registry.yarnpkg.com/global-tld-list/-/global-tld-list-0.0.1093.tgz#223c3e82e1673f36f8d874d42a30f3b8463508de"
integrity sha512-V6ZI9rzpsiVQdEZyMgt4ujKPkR82a+IxmPdMGO7oHc+iBfhdxTTO3nk8+pNUyGCXOHeOCrk7icOKcBMxBMEKkg==
global@^4.4.0:
version "4.4.0"
resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
@ -10031,10 +10078,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
livekit-client@^1.9.6:
version "1.9.6"
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-1.9.6.tgz#4e876714b1d952672be2327e2c8575f1feb28d3c"
integrity sha512-OltbGeo0aazahiFPPPWdrx1zrZsLb34rXaojX8EIegoo+juA7N9i4EtqhADLMeS3Hxtqi1YppqzpFM23SG81eQ==
livekit-client@^1.9.7:
version "1.9.7"
resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-1.9.7.tgz#51e7d8975ce7dcbfd51e91a8f9221f5868a1e606"
integrity sha512-w0WjLat0qF76l71esjTXam5JE+7vCpBF6WW+oloHFNBIVtEzEnBZa2JUo/e2oWN2YypEO5MjCIDPo7Tuvo9clA==
dependencies:
events "^3.3.0"
loglevel "^1.8.0"
@ -10161,7 +10208,7 @@ loglevel@^1.7.1:
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114"
integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==
loglevel@^1.8.0:
loglevel@^1.8.0, loglevel@^1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4"
integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==
@ -12789,7 +12836,7 @@ rw@1:
resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
rxjs@^7.5.2:
rxjs@^7.5.2, rxjs@^7.8.0:
version "7.8.1"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==