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:
		
					parent
					
						
							
								e4f279fa63
							
						
					
				
			
			
				commit
				
					
						f4f5c1ed31
					
				
			
		
					 22 changed files with 579 additions and 1670 deletions
				
			
		| 
						 | 
					@ -18,6 +18,7 @@
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@juggle/resize-observer": "^3.3.1",
 | 
					    "@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",
 | 
					    "@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/button": "^3.3.4",
 | 
				
			||||||
    "@react-aria/dialog": "^3.1.4",
 | 
					    "@react-aria/dialog": "^3.1.4",
 | 
				
			||||||
| 
						 | 
					@ -45,7 +46,7 @@
 | 
				
			||||||
    "i18next": "^21.10.0",
 | 
					    "i18next": "^21.10.0",
 | 
				
			||||||
    "i18next-browser-languagedetector": "^6.1.8",
 | 
					    "i18next-browser-languagedetector": "^6.1.8",
 | 
				
			||||||
    "i18next-http-backend": "^1.4.4",
 | 
					    "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-js-sdk": "github:matrix-org/matrix-js-sdk#b1757b4f9dfe8a1fbb5b8d9ed697ff8b8516413e",
 | 
				
			||||||
    "matrix-widget-api": "^1.0.0",
 | 
					    "matrix-widget-api": "^1.0.0",
 | 
				
			||||||
    "mermaid": "^9.4.0-rc.2",
 | 
					    "mermaid": "^9.4.0-rc.2",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
					 | 
				
			||||||
    </>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -18,23 +18,21 @@ import React, { useCallback, useEffect, useState } from "react";
 | 
				
			||||||
import { useHistory } from "react-router-dom";
 | 
					import { useHistory } from "react-router-dom";
 | 
				
			||||||
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
				
			||||||
import { logger } from "matrix-js-sdk/src/logger";
 | 
					 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
					import type { IWidgetApiRequest } from "matrix-widget-api";
 | 
				
			||||||
import { widget, ElementWidgetActions, JoinCallData } from "../widget";
 | 
					import { widget, ElementWidgetActions } from "../widget";
 | 
				
			||||||
import { useGroupCall } from "./useGroupCall";
 | 
					import { useGroupCall } from "./useGroupCall";
 | 
				
			||||||
import { ErrorView, FullScreenView } from "../FullScreenView";
 | 
					import { ErrorView, FullScreenView } from "../FullScreenView";
 | 
				
			||||||
import { LobbyView } from "./LobbyView";
 | 
					import { LobbyView } from "./LobbyView";
 | 
				
			||||||
 | 
					import { MatrixInfo } from "./VideoPreview";
 | 
				
			||||||
import { InCallView } from "./InCallView";
 | 
					import { InCallView } from "./InCallView";
 | 
				
			||||||
import { PTTCallView } from "./PTTCallView";
 | 
					 | 
				
			||||||
import { CallEndedView } from "./CallEndedView";
 | 
					import { CallEndedView } from "./CallEndedView";
 | 
				
			||||||
import { useRoomAvatar } from "./useRoomAvatar";
 | 
					 | 
				
			||||||
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
 | 
					import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
 | 
				
			||||||
import { useLocationNavigation } from "../useLocationNavigation";
 | 
					import { useLocationNavigation } from "../useLocationNavigation";
 | 
				
			||||||
import { PosthogAnalytics } from "../PosthogAnalytics";
 | 
					import { PosthogAnalytics } from "../PosthogAnalytics";
 | 
				
			||||||
import { useMediaHandler } from "../settings/useMediaHandler";
 | 
					import { useProfile } from "../profile/useProfile";
 | 
				
			||||||
import { findDeviceByName, getDevices } from "../media-utils";
 | 
					import { useLiveKit } from "./useLiveKit";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
declare global {
 | 
					declare global {
 | 
				
			||||||
  interface Window {
 | 
					  interface Window {
 | 
				
			||||||
| 
						 | 
					@ -68,8 +66,6 @@ export function GroupCallView({
 | 
				
			||||||
    userMediaFeeds,
 | 
					    userMediaFeeds,
 | 
				
			||||||
    microphoneMuted,
 | 
					    microphoneMuted,
 | 
				
			||||||
    localVideoMuted,
 | 
					    localVideoMuted,
 | 
				
			||||||
    localCallFeed,
 | 
					 | 
				
			||||||
    initLocalCallFeed,
 | 
					 | 
				
			||||||
    enter,
 | 
					    enter,
 | 
				
			||||||
    leave,
 | 
					    leave,
 | 
				
			||||||
    toggleLocalVideoMuted,
 | 
					    toggleLocalVideoMuted,
 | 
				
			||||||
| 
						 | 
					@ -84,8 +80,6 @@ export function GroupCallView({
 | 
				
			||||||
  } = useGroupCall(groupCall);
 | 
					  } = useGroupCall(groupCall);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const { setAudioInput, setVideoInput } = useMediaHandler();
 | 
					 | 
				
			||||||
  const avatarUrl = useRoomAvatar(groupCall.room);
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    window.groupCall = groupCall;
 | 
					    window.groupCall = groupCall;
 | 
				
			||||||
| 
						 | 
					@ -94,54 +88,22 @@ export function GroupCallView({
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }, [groupCall]);
 | 
					  }, [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(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (widget && preload) {
 | 
					    if (widget && preload) {
 | 
				
			||||||
      // In preload mode, wait for a join action before entering
 | 
					      // In preload mode, wait for a join action before entering
 | 
				
			||||||
      const onJoin = async (ev: CustomEvent<IWidgetApiRequest>) => {
 | 
					      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();
 | 
					        await groupCall.enter();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
 | 
					        PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
 | 
				
			||||||
| 
						 | 
					@ -158,7 +120,7 @@ export function GroupCallView({
 | 
				
			||||||
        widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
 | 
					        widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
 | 
				
			||||||
      };
 | 
					      };
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [groupCall, preload, setAudioInput, setVideoInput]);
 | 
					  }, [groupCall, preload]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  useEffect(() => {
 | 
				
			||||||
    if (isEmbedded && !preload) {
 | 
					    if (isEmbedded && !preload) {
 | 
				
			||||||
| 
						 | 
					@ -225,46 +187,30 @@ export function GroupCallView({
 | 
				
			||||||
  if (error) {
 | 
					  if (error) {
 | 
				
			||||||
    return <ErrorView error={error} />;
 | 
					    return <ErrorView error={error} />;
 | 
				
			||||||
  } else if (state === GroupCallState.Entered) {
 | 
					  } else if (state === GroupCallState.Entered) {
 | 
				
			||||||
    if (groupCall.isPtt) {
 | 
					    return (
 | 
				
			||||||
      return (
 | 
					      <InCallView
 | 
				
			||||||
        <PTTCallView
 | 
					        groupCall={groupCall}
 | 
				
			||||||
          client={client}
 | 
					        client={client}
 | 
				
			||||||
          roomIdOrAlias={roomIdOrAlias}
 | 
					        roomName={groupCall.room.name}
 | 
				
			||||||
          roomName={groupCall.room.name}
 | 
					        avatarUrl={avatarUrl}
 | 
				
			||||||
          avatarUrl={avatarUrl}
 | 
					        participants={participants}
 | 
				
			||||||
          groupCall={groupCall}
 | 
					        mediaDevices={lkState.mediaDevices}
 | 
				
			||||||
          participants={participants}
 | 
					        microphoneMuted={microphoneMuted}
 | 
				
			||||||
          userMediaFeeds={userMediaFeeds}
 | 
					        localVideoMuted={localVideoMuted}
 | 
				
			||||||
          onLeave={onLeave}
 | 
					        toggleLocalVideoMuted={toggleLocalVideoMuted}
 | 
				
			||||||
          isEmbedded={isEmbedded}
 | 
					        toggleMicrophoneMuted={toggleMicrophoneMuted}
 | 
				
			||||||
          hideHeader={hideHeader}
 | 
					        setMicrophoneMuted={setMicrophoneMuted}
 | 
				
			||||||
        />
 | 
					        userMediaFeeds={userMediaFeeds}
 | 
				
			||||||
      );
 | 
					        activeSpeaker={activeSpeaker}
 | 
				
			||||||
    } else {
 | 
					        onLeave={onLeave}
 | 
				
			||||||
      return (
 | 
					        toggleScreensharing={toggleScreensharing}
 | 
				
			||||||
        <InCallView
 | 
					        isScreensharing={isScreensharing}
 | 
				
			||||||
          groupCall={groupCall}
 | 
					        screenshareFeeds={screenshareFeeds}
 | 
				
			||||||
          client={client}
 | 
					        roomIdOrAlias={roomIdOrAlias}
 | 
				
			||||||
          roomName={groupCall.room.name}
 | 
					        unencryptedEventsFromUsers={unencryptedEventsFromUsers}
 | 
				
			||||||
          avatarUrl={avatarUrl}
 | 
					        hideHeader={hideHeader}
 | 
				
			||||||
          participants={participants}
 | 
					      />
 | 
				
			||||||
          microphoneMuted={microphoneMuted}
 | 
					    );
 | 
				
			||||||
          localVideoMuted={localVideoMuted}
 | 
					 | 
				
			||||||
          toggleLocalVideoMuted={toggleLocalVideoMuted}
 | 
					 | 
				
			||||||
          toggleMicrophoneMuted={toggleMicrophoneMuted}
 | 
					 | 
				
			||||||
          setMicrophoneMuted={setMicrophoneMuted}
 | 
					 | 
				
			||||||
          userMediaFeeds={userMediaFeeds}
 | 
					 | 
				
			||||||
          activeSpeaker={activeSpeaker}
 | 
					 | 
				
			||||||
          onLeave={onLeave}
 | 
					 | 
				
			||||||
          toggleScreensharing={toggleScreensharing}
 | 
					 | 
				
			||||||
          isScreensharing={isScreensharing}
 | 
					 | 
				
			||||||
          screenshareFeeds={screenshareFeeds}
 | 
					 | 
				
			||||||
          roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
          unencryptedEventsFromUsers={unencryptedEventsFromUsers}
 | 
					 | 
				
			||||||
          hideHeader={hideHeader}
 | 
					 | 
				
			||||||
        />
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  } else if (left) {
 | 
					  } else if (left) {
 | 
				
			||||||
    if (isPasswordlessUser) {
 | 
					    if (isPasswordlessUser) {
 | 
				
			||||||
      return <CallEndedView client={client} />;
 | 
					      return <CallEndedView client={client} />;
 | 
				
			||||||
| 
						 | 
					@ -282,25 +228,18 @@ export function GroupCallView({
 | 
				
			||||||
        <h1>{t("Loading room…")}</h1>
 | 
					        <h1>{t("Loading room…")}</h1>
 | 
				
			||||||
      </FullScreenView>
 | 
					      </FullScreenView>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  } else {
 | 
					  } else if (lkState) {
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <LobbyView
 | 
					      <LobbyView
 | 
				
			||||||
        client={client}
 | 
					        matrixInfo={matrixInfo}
 | 
				
			||||||
        groupCall={groupCall}
 | 
					        mediaDevices={lkState.mediaDevices}
 | 
				
			||||||
        roomName={groupCall.room.name}
 | 
					        localMedia={lkState.localMedia}
 | 
				
			||||||
        avatarUrl={avatarUrl}
 | 
					 | 
				
			||||||
        state={state}
 | 
					 | 
				
			||||||
        onInitLocalCallFeed={initLocalCallFeed}
 | 
					 | 
				
			||||||
        localCallFeed={localCallFeed}
 | 
					 | 
				
			||||||
        onEnter={enter}
 | 
					        onEnter={enter}
 | 
				
			||||||
        microphoneMuted={microphoneMuted}
 | 
					 | 
				
			||||||
        localVideoMuted={localVideoMuted}
 | 
					 | 
				
			||||||
        toggleLocalVideoMuted={toggleLocalVideoMuted}
 | 
					 | 
				
			||||||
        toggleMicrophoneMuted={toggleMicrophoneMuted}
 | 
					 | 
				
			||||||
        roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
        isEmbedded={isEmbedded}
 | 
					        isEmbedded={isEmbedded}
 | 
				
			||||||
        hideHeader={hideHeader}
 | 
					        hideHeader={hideHeader}
 | 
				
			||||||
      />
 | 
					      />
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
					  } else {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -50,7 +50,6 @@ import { Avatar } from "../Avatar";
 | 
				
			||||||
import { UserMenuContainer } from "../UserMenuContainer";
 | 
					import { UserMenuContainer } from "../UserMenuContainer";
 | 
				
			||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
 | 
					import { useRageshakeRequestModal } from "../settings/submit-rageshake";
 | 
				
			||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
 | 
					import { RageshakeRequestModal } from "./RageshakeRequestModal";
 | 
				
			||||||
import { useMediaHandler } from "../settings/useMediaHandler";
 | 
					 | 
				
			||||||
import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
 | 
					import { useShowInspector, useSpatialAudio } from "../settings/useSetting";
 | 
				
			||||||
import { useModalTriggerState } from "../Modal";
 | 
					import { useModalTriggerState } from "../Modal";
 | 
				
			||||||
import { useAudioContext } from "../video-grid/useMediaStream";
 | 
					import { useAudioContext } from "../video-grid/useMediaStream";
 | 
				
			||||||
| 
						 | 
					@ -64,6 +63,7 @@ import { ParticipantInfo } from "./useGroupCall";
 | 
				
			||||||
import { TileDescriptor } from "../video-grid/TileDescriptor";
 | 
					import { TileDescriptor } from "../video-grid/TileDescriptor";
 | 
				
			||||||
import { AudioSink } from "../video-grid/AudioSink";
 | 
					import { AudioSink } from "../video-grid/AudioSink";
 | 
				
			||||||
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
 | 
					import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
 | 
				
			||||||
 | 
					import { MediaDevicesState } from "./devices/useMediaDevices";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
					const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
 | 
				
			||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
 | 
					// 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>>;
 | 
					  participants: Map<RoomMember, Map<string, ParticipantInfo>>;
 | 
				
			||||||
  roomName: string;
 | 
					  roomName: string;
 | 
				
			||||||
  avatarUrl: string;
 | 
					  avatarUrl: string;
 | 
				
			||||||
 | 
					  mediaDevices: MediaDevicesState;
 | 
				
			||||||
  microphoneMuted: boolean;
 | 
					  microphoneMuted: boolean;
 | 
				
			||||||
  localVideoMuted: boolean;
 | 
					  localVideoMuted: boolean;
 | 
				
			||||||
  toggleLocalVideoMuted: () => void;
 | 
					  toggleLocalVideoMuted: () => void;
 | 
				
			||||||
| 
						 | 
					@ -99,6 +100,7 @@ export function InCallView({
 | 
				
			||||||
  participants,
 | 
					  participants,
 | 
				
			||||||
  roomName,
 | 
					  roomName,
 | 
				
			||||||
  avatarUrl,
 | 
					  avatarUrl,
 | 
				
			||||||
 | 
					  mediaDevices,
 | 
				
			||||||
  microphoneMuted,
 | 
					  microphoneMuted,
 | 
				
			||||||
  localVideoMuted,
 | 
					  localVideoMuted,
 | 
				
			||||||
  toggleLocalVideoMuted,
 | 
					  toggleLocalVideoMuted,
 | 
				
			||||||
| 
						 | 
					@ -349,7 +351,6 @@ export function InCallView({
 | 
				
			||||||
  // audio rendering for feeds that we're displaying, which will need to be fixed
 | 
					  // 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
 | 
					  // once we start having more participants than we can fit on a screen, but this
 | 
				
			||||||
  // is a workaround for now.
 | 
					  // is a workaround for now.
 | 
				
			||||||
  const { audioOutput } = useMediaHandler();
 | 
					 | 
				
			||||||
  const audioElements: JSX.Element[] = [];
 | 
					  const audioElements: JSX.Element[] = [];
 | 
				
			||||||
  if (!spatialAudio || maximisedParticipant) {
 | 
					  if (!spatialAudio || maximisedParticipant) {
 | 
				
			||||||
    for (const item of items) {
 | 
					    for (const item of items) {
 | 
				
			||||||
| 
						 | 
					@ -357,7 +358,7 @@ export function InCallView({
 | 
				
			||||||
      audioElements.push(
 | 
					      audioElements.push(
 | 
				
			||||||
        <AudioSink
 | 
					        <AudioSink
 | 
				
			||||||
          tileDescriptor={item}
 | 
					          tileDescriptor={item}
 | 
				
			||||||
          audioOutput={audioOutput}
 | 
					          audioOutput="AUDIO OUTPUT?"
 | 
				
			||||||
          key={item.id}
 | 
					          key={item.id}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
| 
						 | 
					@ -389,9 +390,9 @@ export function InCallView({
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
        {!maximisedParticipant && (
 | 
					        {!maximisedParticipant && (
 | 
				
			||||||
          <OverflowMenu
 | 
					          <OverflowMenu
 | 
				
			||||||
 | 
					            roomId={roomIdOrAlias}
 | 
				
			||||||
 | 
					            mediaDevices={mediaDevices}
 | 
				
			||||||
            inCall
 | 
					            inCall
 | 
				
			||||||
            roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
            groupCall={groupCall}
 | 
					 | 
				
			||||||
            showInvite={joinRule === JoinRule.Public}
 | 
					            showInvite={joinRule === JoinRule.Public}
 | 
				
			||||||
            feedbackModalState={feedbackModalState}
 | 
					            feedbackModalState={feedbackModalState}
 | 
				
			||||||
            feedbackModalProps={feedbackModalProps}
 | 
					            feedbackModalProps={feedbackModalProps}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -14,90 +14,50 @@ See the License for the specific language governing permissions and
 | 
				
			||||||
limitations under the License.
 | 
					limitations under the License.
 | 
				
			||||||
*/
 | 
					*/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useEffect, useRef } from "react";
 | 
					import React from "react";
 | 
				
			||||||
import { GroupCall, GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					 | 
				
			||||||
import { PressEvent } from "@react-types/shared";
 | 
					import { PressEvent } from "@react-types/shared";
 | 
				
			||||||
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
 | 
					 | 
				
			||||||
import { Trans, useTranslation } from "react-i18next";
 | 
					import { Trans, useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import styles from "./LobbyView.module.css";
 | 
					import styles from "./LobbyView.module.css";
 | 
				
			||||||
import { Button, CopyButton } from "../button";
 | 
					import { Button, CopyButton } from "../button";
 | 
				
			||||||
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
 | 
					import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
 | 
				
			||||||
import { useCallFeed } from "../video-grid/useCallFeed";
 | 
					 | 
				
			||||||
import { getRoomUrl } from "../matrix-utils";
 | 
					import { getRoomUrl } from "../matrix-utils";
 | 
				
			||||||
import { UserMenuContainer } from "../UserMenuContainer";
 | 
					import { UserMenuContainer } from "../UserMenuContainer";
 | 
				
			||||||
import { Body, Link } from "../typography/Typography";
 | 
					import { Body, Link } from "../typography/Typography";
 | 
				
			||||||
import { useLocationNavigation } from "../useLocationNavigation";
 | 
					import { useLocationNavigation } from "../useLocationNavigation";
 | 
				
			||||||
import { useMediaHandler } from "../settings/useMediaHandler";
 | 
					import { LocalMediaInfo, MatrixInfo, VideoPreview } from "./VideoPreview";
 | 
				
			||||||
import { VideoPreview } from "./VideoPreview";
 | 
					import { MediaDevicesState } from "./devices/useMediaDevices";
 | 
				
			||||||
import { AudioPreview } from "./AudioPreview";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  client: MatrixClient;
 | 
					  matrixInfo: MatrixInfo;
 | 
				
			||||||
  groupCall: GroupCall;
 | 
					  mediaDevices: MediaDevicesState;
 | 
				
			||||||
  roomName: string;
 | 
					  localMedia: LocalMediaInfo;
 | 
				
			||||||
  avatarUrl: string;
 | 
					
 | 
				
			||||||
  state: GroupCallState;
 | 
					 | 
				
			||||||
  onInitLocalCallFeed: () => void;
 | 
					 | 
				
			||||||
  onEnter: (e: PressEvent) => void;
 | 
					  onEnter: (e: PressEvent) => void;
 | 
				
			||||||
  localCallFeed: CallFeed;
 | 
					 | 
				
			||||||
  microphoneMuted: boolean;
 | 
					 | 
				
			||||||
  toggleLocalVideoMuted: () => void;
 | 
					 | 
				
			||||||
  toggleMicrophoneMuted: () => void;
 | 
					 | 
				
			||||||
  localVideoMuted: boolean;
 | 
					 | 
				
			||||||
  roomIdOrAlias: string;
 | 
					 | 
				
			||||||
  isEmbedded: boolean;
 | 
					  isEmbedded: boolean;
 | 
				
			||||||
  hideHeader: boolean;
 | 
					  hideHeader: boolean;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
export function LobbyView({
 | 
					
 | 
				
			||||||
  client,
 | 
					export function LobbyView(props: Props) {
 | 
				
			||||||
  groupCall,
 | 
					 | 
				
			||||||
  roomName,
 | 
					 | 
				
			||||||
  avatarUrl,
 | 
					 | 
				
			||||||
  state,
 | 
					 | 
				
			||||||
  onInitLocalCallFeed,
 | 
					 | 
				
			||||||
  onEnter,
 | 
					 | 
				
			||||||
  localCallFeed,
 | 
					 | 
				
			||||||
  microphoneMuted,
 | 
					 | 
				
			||||||
  localVideoMuted,
 | 
					 | 
				
			||||||
  toggleLocalVideoMuted,
 | 
					 | 
				
			||||||
  toggleMicrophoneMuted,
 | 
					 | 
				
			||||||
  roomIdOrAlias,
 | 
					 | 
				
			||||||
  isEmbedded,
 | 
					 | 
				
			||||||
  hideHeader,
 | 
					 | 
				
			||||||
}: Props) {
 | 
					 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const { stream } = useCallFeed(localCallFeed);
 | 
					  useLocationNavigation();
 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    audioInput,
 | 
					 | 
				
			||||||
    audioInputs,
 | 
					 | 
				
			||||||
    setAudioInput,
 | 
					 | 
				
			||||||
    audioOutput,
 | 
					 | 
				
			||||||
    audioOutputs,
 | 
					 | 
				
			||||||
    setAudioOutput,
 | 
					 | 
				
			||||||
  } = useMediaHandler();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  useEffect(() => {
 | 
					  const joinCallButtonRef = React.useRef<HTMLButtonElement>();
 | 
				
			||||||
    onInitLocalCallFeed();
 | 
					  React.useEffect(() => {
 | 
				
			||||||
  }, [onInitLocalCallFeed]);
 | 
					    if (joinCallButtonRef.current) {
 | 
				
			||||||
 | 
					 | 
				
			||||||
  useLocationNavigation(state === GroupCallState.InitializingLocalCallFeed);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const joinCallButtonRef = useRef<HTMLButtonElement>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  useEffect(() => {
 | 
					 | 
				
			||||||
    if (state === GroupCallState.LocalCallFeedInitialized) {
 | 
					 | 
				
			||||||
      joinCallButtonRef.current.focus();
 | 
					      joinCallButtonRef.current.focus();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }, [state]);
 | 
					  }, [joinCallButtonRef]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className={styles.room}>
 | 
					    <div className={styles.room}>
 | 
				
			||||||
      {!hideHeader && (
 | 
					      {!props.hideHeader && (
 | 
				
			||||||
        <Header>
 | 
					        <Header>
 | 
				
			||||||
          <LeftNav>
 | 
					          <LeftNav>
 | 
				
			||||||
            <RoomHeaderInfo roomName={roomName} avatarUrl={avatarUrl} />
 | 
					            <RoomHeaderInfo
 | 
				
			||||||
 | 
					              roomName={props.matrixInfo.roomName}
 | 
				
			||||||
 | 
					              avatarUrl={props.matrixInfo.avatarUrl}
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
          </LeftNav>
 | 
					          </LeftNav>
 | 
				
			||||||
          <RightNav>
 | 
					          <RightNav>
 | 
				
			||||||
            <UserMenuContainer />
 | 
					            <UserMenuContainer />
 | 
				
			||||||
| 
						 | 
					@ -106,44 +66,24 @@ export function LobbyView({
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
      <div className={styles.joinRoom}>
 | 
					      <div className={styles.joinRoom}>
 | 
				
			||||||
        <div className={styles.joinRoomContent}>
 | 
					        <div className={styles.joinRoomContent}>
 | 
				
			||||||
          {groupCall.isPtt ? (
 | 
					          <VideoPreview
 | 
				
			||||||
            <AudioPreview
 | 
					            matrixInfo={props.matrixInfo}
 | 
				
			||||||
              roomName={roomName}
 | 
					            mediaDevices={props.mediaDevices}
 | 
				
			||||||
              state={state}
 | 
					            localMediaInfo={props.localMedia}
 | 
				
			||||||
              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}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          <Trans>
 | 
					          <Trans>
 | 
				
			||||||
            <Button
 | 
					            <Button
 | 
				
			||||||
              ref={joinCallButtonRef}
 | 
					              ref={joinCallButtonRef}
 | 
				
			||||||
              className={styles.copyButton}
 | 
					              className={styles.copyButton}
 | 
				
			||||||
              size="lg"
 | 
					              size="lg"
 | 
				
			||||||
              disabled={state !== GroupCallState.LocalCallFeedInitialized}
 | 
					              onPress={props.onEnter}
 | 
				
			||||||
              onPress={onEnter}
 | 
					 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
              Join call now
 | 
					              Join call now
 | 
				
			||||||
            </Button>
 | 
					            </Button>
 | 
				
			||||||
            <Body>Or</Body>
 | 
					            <Body>Or</Body>
 | 
				
			||||||
            <CopyButton
 | 
					            <CopyButton
 | 
				
			||||||
              variant="secondaryCopy"
 | 
					              variant="secondaryCopy"
 | 
				
			||||||
              value={getRoomUrl(roomIdOrAlias)}
 | 
					              value={getRoomUrl(props.matrixInfo.roomName)}
 | 
				
			||||||
              className={styles.copyButton}
 | 
					              className={styles.copyButton}
 | 
				
			||||||
              copiedMessage={t("Call link copied")}
 | 
					              copiedMessage={t("Call link copied")}
 | 
				
			||||||
            >
 | 
					            >
 | 
				
			||||||
| 
						 | 
					@ -151,7 +91,7 @@ export function LobbyView({
 | 
				
			||||||
            </CopyButton>
 | 
					            </CopyButton>
 | 
				
			||||||
          </Trans>
 | 
					          </Trans>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
        {!isEmbedded && (
 | 
					        {!props.isEmbedded && (
 | 
				
			||||||
          <Body className={styles.joinRoomFooter}>
 | 
					          <Body className={styles.joinRoomFooter}>
 | 
				
			||||||
            <Link color="primary" to="/">
 | 
					            <Link color="primary" to="/">
 | 
				
			||||||
              {t("Take me Home")}
 | 
					              {t("Take me Home")}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -16,7 +16,6 @@ limitations under the License.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import React, { useCallback } from "react";
 | 
					import React, { useCallback } from "react";
 | 
				
			||||||
import { Item } from "@react-stately/collections";
 | 
					import { Item } from "@react-stately/collections";
 | 
				
			||||||
import { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					 | 
				
			||||||
import { OverlayTriggerState } from "@react-stately/overlays";
 | 
					import { OverlayTriggerState } from "@react-stately/overlays";
 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					import { useTranslation } from "react-i18next";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,11 +32,13 @@ import { InviteModal } from "./InviteModal";
 | 
				
			||||||
import { TooltipTrigger } from "../Tooltip";
 | 
					import { TooltipTrigger } from "../Tooltip";
 | 
				
			||||||
import { FeedbackModal } from "./FeedbackModal";
 | 
					import { FeedbackModal } from "./FeedbackModal";
 | 
				
			||||||
import { Config } from "../config/Config";
 | 
					import { Config } from "../config/Config";
 | 
				
			||||||
 | 
					import { MediaDevicesState } from "./devices/useMediaDevices";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
  roomIdOrAlias: string;
 | 
					  roomId: string;
 | 
				
			||||||
 | 
					  mediaDevices: MediaDevicesState;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  inCall: boolean;
 | 
					  inCall: boolean;
 | 
				
			||||||
  groupCall: GroupCall;
 | 
					 | 
				
			||||||
  showInvite: boolean;
 | 
					  showInvite: boolean;
 | 
				
			||||||
  feedbackModalState: OverlayTriggerState;
 | 
					  feedbackModalState: OverlayTriggerState;
 | 
				
			||||||
  feedbackModalProps: {
 | 
					  feedbackModalProps: {
 | 
				
			||||||
| 
						 | 
					@ -46,16 +47,8 @@ interface Props {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function OverflowMenu({
 | 
					export function OverflowMenu(props: Props) {
 | 
				
			||||||
  roomIdOrAlias,
 | 
					 | 
				
			||||||
  inCall,
 | 
					 | 
				
			||||||
  groupCall,
 | 
					 | 
				
			||||||
  showInvite,
 | 
					 | 
				
			||||||
  feedbackModalState,
 | 
					 | 
				
			||||||
  feedbackModalProps,
 | 
					 | 
				
			||||||
}: Props) {
 | 
					 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
 | 
					 | 
				
			||||||
  const {
 | 
					  const {
 | 
				
			||||||
    modalState: inviteModalState,
 | 
					    modalState: inviteModalState,
 | 
				
			||||||
    modalProps: inviteModalProps,
 | 
					    modalProps: inviteModalProps,
 | 
				
			||||||
| 
						 | 
					@ -89,11 +82,11 @@ export function OverflowMenu({
 | 
				
			||||||
          settingsModalState.open();
 | 
					          settingsModalState.open();
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
        case "feedback":
 | 
					        case "feedback":
 | 
				
			||||||
          feedbackModalState.open();
 | 
					          props.feedbackModalState.open();
 | 
				
			||||||
          break;
 | 
					          break;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
    [feedbackModalState, inviteModalState, settingsModalState]
 | 
					    [props.feedbackModalState, inviteModalState, settingsModalState]
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const tooltip = useCallback(() => t("More"), [t]);
 | 
					  const tooltip = useCallback(() => t("More"), [t]);
 | 
				
			||||||
| 
						 | 
					@ -106,9 +99,9 @@ export function OverflowMenu({
 | 
				
			||||||
            <OverflowIcon />
 | 
					            <OverflowIcon />
 | 
				
			||||||
          </Button>
 | 
					          </Button>
 | 
				
			||||||
        </TooltipTrigger>
 | 
					        </TooltipTrigger>
 | 
				
			||||||
        {(props: JSX.IntrinsicAttributes) => (
 | 
					        {(attr: JSX.IntrinsicAttributes) => (
 | 
				
			||||||
          <Menu {...props} label={t("More menu")} onAction={onAction}>
 | 
					          <Menu {...attr} label={t("More menu")} onAction={onAction}>
 | 
				
			||||||
            {showInvite && (
 | 
					            {props.showInvite && (
 | 
				
			||||||
              <Item key="invite" textValue={t("Invite people")}>
 | 
					              <Item key="invite" textValue={t("Invite people")}>
 | 
				
			||||||
                <AddUserIcon />
 | 
					                <AddUserIcon />
 | 
				
			||||||
                <span>{t("Invite people")}</span>
 | 
					                <span>{t("Invite people")}</span>
 | 
				
			||||||
| 
						 | 
					@ -127,15 +120,20 @@ export function OverflowMenu({
 | 
				
			||||||
          </Menu>
 | 
					          </Menu>
 | 
				
			||||||
        )}
 | 
					        )}
 | 
				
			||||||
      </PopoverMenuTrigger>
 | 
					      </PopoverMenuTrigger>
 | 
				
			||||||
      {settingsModalState.isOpen && <SettingsModal {...settingsModalProps} />}
 | 
					      {settingsModalState.isOpen && (
 | 
				
			||||||
      {inviteModalState.isOpen && (
 | 
					        <SettingsModal
 | 
				
			||||||
        <InviteModal roomIdOrAlias={roomIdOrAlias} {...inviteModalProps} />
 | 
					          mediaDevices={props.mediaDevices}
 | 
				
			||||||
 | 
					          {...settingsModalProps}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
      {feedbackModalState.isOpen && (
 | 
					      {inviteModalState.isOpen && (
 | 
				
			||||||
 | 
					        <InviteModal roomIdOrAlias={props.roomId} {...inviteModalProps} />
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      {props.feedbackModalState.isOpen && (
 | 
				
			||||||
        <FeedbackModal
 | 
					        <FeedbackModal
 | 
				
			||||||
          {...feedbackModalProps}
 | 
					          roomId={props.roomId}
 | 
				
			||||||
          roomId={groupCall?.room.roomId}
 | 
					          inCall={props.inCall}
 | 
				
			||||||
          inCall={inCall}
 | 
					          {...props.feedbackModalProps}
 | 
				
			||||||
        />
 | 
					        />
 | 
				
			||||||
      )}
 | 
					      )}
 | 
				
			||||||
    </>
 | 
					    </>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
					 | 
				
			||||||
  );
 | 
					 | 
				
			||||||
};
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -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 />;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -24,7 +24,6 @@ import { RoomAuthView } from "./RoomAuthView";
 | 
				
			||||||
import { GroupCallLoader } from "./GroupCallLoader";
 | 
					import { GroupCallLoader } from "./GroupCallLoader";
 | 
				
			||||||
import { GroupCallView } from "./GroupCallView";
 | 
					import { GroupCallView } from "./GroupCallView";
 | 
				
			||||||
import { useUrlParams } from "../UrlParams";
 | 
					import { useUrlParams } from "../UrlParams";
 | 
				
			||||||
import { MediaHandlerProvider } from "../settings/useMediaHandler";
 | 
					 | 
				
			||||||
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
 | 
					import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
 | 
				
			||||||
import { translatedError } from "../TranslatedError";
 | 
					import { translatedError } from "../TranslatedError";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -94,15 +93,13 @@ export const RoomPage: FC = () => {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <MediaHandlerProvider client={client}>
 | 
					    <GroupCallLoader
 | 
				
			||||||
      <GroupCallLoader
 | 
					      client={client}
 | 
				
			||||||
        client={client}
 | 
					      roomIdOrAlias={roomIdOrAlias}
 | 
				
			||||||
        roomIdOrAlias={roomIdOrAlias}
 | 
					      viaServers={viaServers}
 | 
				
			||||||
        viaServers={viaServers}
 | 
					      createPtt={isPtt}
 | 
				
			||||||
        createPtt={isPtt}
 | 
					    >
 | 
				
			||||||
      >
 | 
					      {groupCallView}
 | 
				
			||||||
        {groupCallView}
 | 
					    </GroupCallLoader>
 | 
				
			||||||
      </GroupCallLoader>
 | 
					 | 
				
			||||||
    </MediaHandlerProvider>
 | 
					 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -17,95 +17,98 @@ limitations under the License.
 | 
				
			||||||
import React from "react";
 | 
					import React from "react";
 | 
				
			||||||
import useMeasure from "react-use-measure";
 | 
					import useMeasure from "react-use-measure";
 | 
				
			||||||
import { ResizeObserver } from "@juggle/resize-observer";
 | 
					import { ResizeObserver } from "@juggle/resize-observer";
 | 
				
			||||||
import { GroupCallState } from "matrix-js-sdk/src/webrtc/groupCall";
 | 
					import { Track } from "livekit-client";
 | 
				
			||||||
import { MatrixClient } from "matrix-js-sdk/src/client";
 | 
					 | 
				
			||||||
import { useTranslation } from "react-i18next";
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import { MicButton, VideoButton } from "../button";
 | 
					import { MicButton, VideoButton } from "../button";
 | 
				
			||||||
import { useMediaStream } from "../video-grid/useMediaStream";
 | 
					 | 
				
			||||||
import { OverflowMenu } from "./OverflowMenu";
 | 
					import { OverflowMenu } from "./OverflowMenu";
 | 
				
			||||||
import { Avatar } from "../Avatar";
 | 
					import { Avatar } from "../Avatar";
 | 
				
			||||||
import { useProfile } from "../profile/useProfile";
 | 
					 | 
				
			||||||
import styles from "./VideoPreview.module.css";
 | 
					import styles from "./VideoPreview.module.css";
 | 
				
			||||||
import { Body } from "../typography/Typography";
 | 
					 | 
				
			||||||
import { useModalTriggerState } from "../Modal";
 | 
					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 {
 | 
					interface Props {
 | 
				
			||||||
  client: MatrixClient;
 | 
					  matrixInfo: MatrixInfo;
 | 
				
			||||||
  state: GroupCallState;
 | 
					  mediaDevices: MediaDevicesState;
 | 
				
			||||||
  roomIdOrAlias: string;
 | 
					  localMediaInfo: LocalMediaInfo;
 | 
				
			||||||
  microphoneMuted: boolean;
 | 
					 | 
				
			||||||
  localVideoMuted: boolean;
 | 
					 | 
				
			||||||
  toggleLocalVideoMuted: () => void;
 | 
					 | 
				
			||||||
  toggleMicrophoneMuted: () => void;
 | 
					 | 
				
			||||||
  audioOutput: string;
 | 
					 | 
				
			||||||
  stream: MediaStream;
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function VideoPreview({
 | 
					export function VideoPreview({
 | 
				
			||||||
  client,
 | 
					  matrixInfo,
 | 
				
			||||||
  state,
 | 
					  mediaDevices,
 | 
				
			||||||
  roomIdOrAlias,
 | 
					  localMediaInfo,
 | 
				
			||||||
  microphoneMuted,
 | 
					 | 
				
			||||||
  localVideoMuted,
 | 
					 | 
				
			||||||
  toggleLocalVideoMuted,
 | 
					 | 
				
			||||||
  toggleMicrophoneMuted,
 | 
					 | 
				
			||||||
  audioOutput,
 | 
					 | 
				
			||||||
  stream,
 | 
					 | 
				
			||||||
}: Props) {
 | 
					}: Props) {
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					 | 
				
			||||||
  const videoRef = useMediaStream(stream, audioOutput, true);
 | 
					 | 
				
			||||||
  const { displayName, avatarUrl } = useProfile(client);
 | 
					 | 
				
			||||||
  const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
 | 
					  const [previewRef, previewBounds] = useMeasure({ polyfill: ResizeObserver });
 | 
				
			||||||
  const avatarSize = (previewBounds.height - 66) / 2;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
 | 
					  const { modalState: feedbackModalState, modalProps: feedbackModalProps } =
 | 
				
			||||||
    useModalTriggerState();
 | 
					    useModalTriggerState();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const mediaElement = React.useRef(null);
 | 
				
			||||||
 | 
					  React.useEffect(() => {
 | 
				
			||||||
 | 
					    if (mediaElement.current) {
 | 
				
			||||||
 | 
					      localMediaInfo.video?.track.attach(mediaElement.current);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return () => {
 | 
				
			||||||
 | 
					      localMediaInfo.video?.track.detach();
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }, [localMediaInfo]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  return (
 | 
					  return (
 | 
				
			||||||
    <div className={styles.preview} ref={previewRef}>
 | 
					    <div className={styles.preview} ref={previewRef}>
 | 
				
			||||||
      <video ref={videoRef} muted playsInline disablePictureInPicture />
 | 
					      <video ref={mediaElement} muted playsInline disablePictureInPicture />
 | 
				
			||||||
      {state === GroupCallState.LocalCallFeedUninitialized && (
 | 
					      <>
 | 
				
			||||||
        <Body fontWeight="semiBold" className={styles.cameraPermissions}>
 | 
					        {(localMediaInfo.video?.muted ?? true) && (
 | 
				
			||||||
          {t("Camera/microphone permissions needed to join the call.")}
 | 
					          <div className={styles.avatarContainer}>
 | 
				
			||||||
        </Body>
 | 
					            <Avatar
 | 
				
			||||||
      )}
 | 
					              size={(previewBounds.height - 66) / 2}
 | 
				
			||||||
      {state === GroupCallState.InitializingLocalCallFeed && (
 | 
					              src={matrixInfo.avatarUrl}
 | 
				
			||||||
        <Body fontWeight="semiBold" className={styles.cameraPermissions}>
 | 
					              fallback={matrixInfo.userName.slice(0, 1).toUpperCase()}
 | 
				
			||||||
          {t("Accept camera/microphone permissions to join the call.")}
 | 
					 | 
				
			||||||
        </Body>
 | 
					 | 
				
			||||||
      )}
 | 
					 | 
				
			||||||
      {state === GroupCallState.LocalCallFeedInitialized && (
 | 
					 | 
				
			||||||
        <>
 | 
					 | 
				
			||||||
          {localVideoMuted && (
 | 
					 | 
				
			||||||
            <div className={styles.avatarContainer}>
 | 
					 | 
				
			||||||
              <Avatar
 | 
					 | 
				
			||||||
                size={avatarSize}
 | 
					 | 
				
			||||||
                src={avatarUrl}
 | 
					 | 
				
			||||||
                fallback={displayName.slice(0, 1).toUpperCase()}
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
            </div>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          <div className={styles.previewButtons}>
 | 
					 | 
				
			||||||
            <MicButton
 | 
					 | 
				
			||||||
              muted={microphoneMuted}
 | 
					 | 
				
			||||||
              onPress={toggleMicrophoneMuted}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <VideoButton
 | 
					 | 
				
			||||||
              muted={localVideoMuted}
 | 
					 | 
				
			||||||
              onPress={toggleLocalVideoMuted}
 | 
					 | 
				
			||||||
            />
 | 
					 | 
				
			||||||
            <OverflowMenu
 | 
					 | 
				
			||||||
              roomIdOrAlias={roomIdOrAlias}
 | 
					 | 
				
			||||||
              feedbackModalState={feedbackModalState}
 | 
					 | 
				
			||||||
              feedbackModalProps={feedbackModalProps}
 | 
					 | 
				
			||||||
              inCall={false}
 | 
					 | 
				
			||||||
              groupCall={undefined}
 | 
					 | 
				
			||||||
              showInvite={false}
 | 
					 | 
				
			||||||
            />
 | 
					            />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
        </>
 | 
					        )}
 | 
				
			||||||
      )}
 | 
					        <div className={styles.previewButtons}>
 | 
				
			||||||
 | 
					          {localMediaInfo.audio && (
 | 
				
			||||||
 | 
					            <MicButton
 | 
				
			||||||
 | 
					              muted={localMediaInfo.audio?.muted}
 | 
				
			||||||
 | 
					              onPress={() =>
 | 
				
			||||||
 | 
					                localMediaInfo.audio?.setMuted(!localMediaInfo.audio?.muted)
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          {localMediaInfo.video && (
 | 
				
			||||||
 | 
					            <VideoButton
 | 
				
			||||||
 | 
					              muted={localMediaInfo.video?.muted}
 | 
				
			||||||
 | 
					              onPress={() =>
 | 
				
			||||||
 | 
					                localMediaInfo.video?.setMuted(!localMediaInfo.video?.muted)
 | 
				
			||||||
 | 
					              }
 | 
				
			||||||
 | 
					            />
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					          <OverflowMenu
 | 
				
			||||||
 | 
					            roomId={matrixInfo.roomId}
 | 
				
			||||||
 | 
					            mediaDevices={mediaDevices}
 | 
				
			||||||
 | 
					            feedbackModalState={feedbackModalState}
 | 
				
			||||||
 | 
					            feedbackModalProps={feedbackModalProps}
 | 
				
			||||||
 | 
					            inCall={false}
 | 
				
			||||||
 | 
					            showInvite={false}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </>
 | 
				
			||||||
    </div>
 | 
					    </div>
 | 
				
			||||||
  );
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										18
									
								
								src/room/devices/mediaDevices.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								src/room/devices/mediaDevices.ts
									
										
									
									
									
										Normal 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",
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										149
									
								
								src/room/devices/useMediaDevices.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										149
									
								
								src/room/devices/useMediaDevices.ts
									
										
									
									
									
										Normal 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
									
								
							
							
						
						
									
										145
									
								
								src/room/useLiveKit.ts
									
										
									
									
									
										Normal 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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -26,7 +26,7 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
 | 
				
			||||||
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
 | 
					import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
 | 
				
			||||||
import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
 | 
					import { ReactComponent as OverflowIcon } from "../icons/Overflow.svg";
 | 
				
			||||||
import { SelectInput } from "../input/SelectInput";
 | 
					import { SelectInput } from "../input/SelectInput";
 | 
				
			||||||
import { useMediaHandler } from "./useMediaHandler";
 | 
					import { MediaDevicesState } from "../room/devices/useMediaDevices";
 | 
				
			||||||
import {
 | 
					import {
 | 
				
			||||||
  useKeyboardShortcuts,
 | 
					  useKeyboardShortcuts,
 | 
				
			||||||
  useSpatialAudio,
 | 
					  useSpatialAudio,
 | 
				
			||||||
| 
						 | 
					@ -40,23 +40,13 @@ import { useDownloadDebugLog } from "./submit-rageshake";
 | 
				
			||||||
import { Body } from "../typography/Typography";
 | 
					import { Body } from "../typography/Typography";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface Props {
 | 
					interface Props {
 | 
				
			||||||
 | 
					  mediaDevices: MediaDevicesState;
 | 
				
			||||||
  isOpen: boolean;
 | 
					  isOpen: boolean;
 | 
				
			||||||
  onClose: () => void;
 | 
					  onClose: () => void;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const SettingsModal = (props: Props) => {
 | 
					export const SettingsModal = (props: Props) => {
 | 
				
			||||||
  const { t } = useTranslation();
 | 
					  const { t } = useTranslation();
 | 
				
			||||||
  const {
 | 
					 | 
				
			||||||
    audioInput,
 | 
					 | 
				
			||||||
    audioInputs,
 | 
					 | 
				
			||||||
    setAudioInput,
 | 
					 | 
				
			||||||
    videoInput,
 | 
					 | 
				
			||||||
    videoInputs,
 | 
					 | 
				
			||||||
    setVideoInput,
 | 
					 | 
				
			||||||
    audioOutput,
 | 
					 | 
				
			||||||
    audioOutputs,
 | 
					 | 
				
			||||||
    setAudioOutput,
 | 
					 | 
				
			||||||
  } = useMediaHandler();
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const [spatialAudio, setSpatialAudio] = useSpatialAudio();
 | 
					  const [spatialAudio, setSpatialAudio] = useSpatialAudio();
 | 
				
			||||||
  const [showInspector, setShowInspector] = useShowInspector();
 | 
					  const [showInspector, setShowInspector] = useShowInspector();
 | 
				
			||||||
| 
						 | 
					@ -65,6 +55,30 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  const downloadDebugLog = useDownloadDebugLog();
 | 
					  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 (
 | 
					  return (
 | 
				
			||||||
    <Modal
 | 
					    <Modal
 | 
				
			||||||
      title={t("Settings")}
 | 
					      title={t("Settings")}
 | 
				
			||||||
| 
						 | 
					@ -82,34 +96,8 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <SelectInput
 | 
					          {generateDeviceSelection("audioinput", t("Microphone"))}
 | 
				
			||||||
            label={t("Microphone")}
 | 
					          {generateDeviceSelection("audiooutput", t("Speaker"))}
 | 
				
			||||||
            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>
 | 
					 | 
				
			||||||
          )}
 | 
					 | 
				
			||||||
          <FieldRow>
 | 
					          <FieldRow>
 | 
				
			||||||
            <InputField
 | 
					            <InputField
 | 
				
			||||||
              id="spatialAudio"
 | 
					              id="spatialAudio"
 | 
				
			||||||
| 
						 | 
					@ -138,19 +126,7 @@ export const SettingsModal = (props: Props) => {
 | 
				
			||||||
            </>
 | 
					            </>
 | 
				
			||||||
          }
 | 
					          }
 | 
				
			||||||
        >
 | 
					        >
 | 
				
			||||||
          <SelectInput
 | 
					          {generateDeviceSelection("videoinput", t("Camera"))}
 | 
				
			||||||
            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>
 | 
					 | 
				
			||||||
        </TabItem>
 | 
					        </TabItem>
 | 
				
			||||||
        <TabItem
 | 
					        <TabItem
 | 
				
			||||||
          title={
 | 
					          title={
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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);
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										61
									
								
								yarn.lock
									
										
									
									
									
								
							
							
						
						
									
										61
									
								
								yarn.lock
									
										
									
									
									
								
							| 
						 | 
					@ -1429,6 +1429,18 @@
 | 
				
			||||||
    minimatch "^3.1.2"
 | 
					    minimatch "^3.1.2"
 | 
				
			||||||
    strip-json-comments "^3.1.1"
 | 
					    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":
 | 
					"@formatjs/ecma402-abstract@1.11.4":
 | 
				
			||||||
  version "1.11.4"
 | 
					  version "1.11.4"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz#b962dfc4ae84361f9f08fbce411b4e4340930eda"
 | 
					  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"
 | 
					  resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
 | 
				
			||||||
  integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
 | 
					  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":
 | 
					"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.3":
 | 
				
			||||||
  version "0.1.0-alpha.4"
 | 
					  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"
 | 
					  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"
 | 
					    "@react-aria/utils" "^3.13.1"
 | 
				
			||||||
    clsx "^1.1.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":
 | 
					"@react-spring/animated@~9.4.5":
 | 
				
			||||||
  version "9.4.5"
 | 
					  version "9.4.5"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-9.4.5.tgz#dd9921c716a4f4a3ed29491e0c0c9f8ca0eb1a54"
 | 
					  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"
 | 
					    process-nextick-args "^2.0.0"
 | 
				
			||||||
    readable-stream "^2.3.5"
 | 
					    readable-stream "^2.3.5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
clsx@^1.1.1:
 | 
					clsx@^1.1.1, clsx@^1.2.1:
 | 
				
			||||||
  version "1.2.1"
 | 
					  version "1.2.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
 | 
					  resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
 | 
				
			||||||
  integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
 | 
					  integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
 | 
				
			||||||
| 
						 | 
					@ -6693,6 +6730,11 @@ elliptic@^6.5.3:
 | 
				
			||||||
    minimalistic-assert "^1.0.1"
 | 
					    minimalistic-assert "^1.0.1"
 | 
				
			||||||
    minimalistic-crypto-utils "^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:
 | 
					emittery@^0.13.1:
 | 
				
			||||||
  version "0.13.1"
 | 
					  version "0.13.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
 | 
					  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"
 | 
					    once "^1.3.0"
 | 
				
			||||||
    path-is-absolute "^1.0.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:
 | 
					global@^4.4.0:
 | 
				
			||||||
  version "4.4.0"
 | 
					  version "4.4.0"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/global/-/global-4.4.0.tgz#3e7b105179006a323ed71aafca3e9c57a5cc6406"
 | 
					  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"
 | 
					  resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
 | 
				
			||||||
  integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
 | 
					  integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
livekit-client@^1.9.6:
 | 
					livekit-client@^1.9.7:
 | 
				
			||||||
  version "1.9.6"
 | 
					  version "1.9.7"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-1.9.6.tgz#4e876714b1d952672be2327e2c8575f1feb28d3c"
 | 
					  resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-1.9.7.tgz#51e7d8975ce7dcbfd51e91a8f9221f5868a1e606"
 | 
				
			||||||
  integrity sha512-OltbGeo0aazahiFPPPWdrx1zrZsLb34rXaojX8EIegoo+juA7N9i4EtqhADLMeS3Hxtqi1YppqzpFM23SG81eQ==
 | 
					  integrity sha512-w0WjLat0qF76l71esjTXam5JE+7vCpBF6WW+oloHFNBIVtEzEnBZa2JUo/e2oWN2YypEO5MjCIDPo7Tuvo9clA==
 | 
				
			||||||
  dependencies:
 | 
					  dependencies:
 | 
				
			||||||
    events "^3.3.0"
 | 
					    events "^3.3.0"
 | 
				
			||||||
    loglevel "^1.8.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"
 | 
					  resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.0.tgz#e7ec73a57e1e7b419cb6c6ac06bf050b67356114"
 | 
				
			||||||
  integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==
 | 
					  integrity sha512-G6A/nJLRgWOuuwdNuA6koovfEV1YpqqAG4pRUlFaz3jj2QNZ8M4vBqnVA+HBTmU/AMNUtlOsMmSpF6NyOjztbA==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
loglevel@^1.8.0:
 | 
					loglevel@^1.8.0, loglevel@^1.8.1:
 | 
				
			||||||
  version "1.8.1"
 | 
					  version "1.8.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4"
 | 
					  resolved "https://registry.yarnpkg.com/loglevel/-/loglevel-1.8.1.tgz#5c621f83d5b48c54ae93b6156353f555963377b4"
 | 
				
			||||||
  integrity sha512-tCRIJM51SHjAayKwC+QAg8hT8vg6z7GSgLJKGvzuPb1Wc+hLzqtuVLxp6/HzSPOozuK+8ErAhy7U/sVzw8Dgfg==
 | 
					  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"
 | 
					  resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4"
 | 
				
			||||||
  integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
 | 
					  integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
rxjs@^7.5.2:
 | 
					rxjs@^7.5.2, rxjs@^7.8.0:
 | 
				
			||||||
  version "7.8.1"
 | 
					  version "7.8.1"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
 | 
					  resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543"
 | 
				
			||||||
  integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
 | 
					  integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue