Merge branch 'main' into SimonBrandner/feat/settings
This commit is contained in:
@@ -134,7 +134,9 @@ export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
|
||||
/>
|
||||
<VideoIcon width={16} height={16} />
|
||||
</div>
|
||||
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
|
||||
<Subtitle data-testid="roomHeader_roomName" fontWeight="semiBold">
|
||||
{roomName}
|
||||
</Subtitle>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ export function Modal({
|
||||
{...closeButtonProps}
|
||||
ref={closeButtonRef}
|
||||
className={styles.closeButton}
|
||||
data-testid="modal_close"
|
||||
title={t("Close")}
|
||||
>
|
||||
<CloseIcon />
|
||||
|
||||
@@ -59,6 +59,7 @@ export function UserMenu({
|
||||
key: "user",
|
||||
icon: UserIcon,
|
||||
label: displayName,
|
||||
dataTestid: "usermenu_user",
|
||||
});
|
||||
arr.push({
|
||||
key: "settings",
|
||||
@@ -71,6 +72,7 @@ export function UserMenu({
|
||||
key: "login",
|
||||
label: t("Sign in"),
|
||||
icon: LoginIcon,
|
||||
dataTestid: "usermenu_login",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +81,7 @@ export function UserMenu({
|
||||
key: "logout",
|
||||
label: t("Sign out"),
|
||||
icon: LogoutIcon,
|
||||
dataTestid: "usermenu_logout",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -99,7 +102,11 @@ export function UserMenu({
|
||||
return (
|
||||
<PopoverMenuTrigger placement="bottom right">
|
||||
<TooltipTrigger tooltip={tooltip} placement="bottom left">
|
||||
<Button variant="icon" className={styles.userButton}>
|
||||
<Button
|
||||
variant="icon"
|
||||
className={styles.userButton}
|
||||
data-testid="usermenu_open"
|
||||
>
|
||||
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
|
||||
<Avatar
|
||||
size={Size.SM}
|
||||
@@ -114,9 +121,14 @@ export function UserMenu({
|
||||
</TooltipTrigger>
|
||||
{(props) => (
|
||||
<Menu {...props} label={t("User menu")} onAction={onAction}>
|
||||
{items.map(({ key, icon: Icon, label }) => (
|
||||
{items.map(({ key, icon: Icon, label, dataTestid }) => (
|
||||
<Item key={key} textValue={label}>
|
||||
<Icon width={24} height={24} className={styles.menuIcon} />
|
||||
<Icon
|
||||
width={24}
|
||||
height={24}
|
||||
className={styles.menuIcon}
|
||||
data-testid={dataTestid}
|
||||
/>
|
||||
<Body overflowEllipsis>{label}</Body>
|
||||
</Item>
|
||||
))}
|
||||
|
||||
@@ -124,6 +124,8 @@ export class PosthogSpanProcessor implements SpanProcessor {
|
||||
const audioReceived = `${attributes["matrix.stats.summary.percentageReceivedAudioMedia"]}`;
|
||||
const maxJitter = `${attributes["matrix.stats.summary.maxJitter"]}`;
|
||||
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
|
||||
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
|
||||
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
|
||||
PosthogAnalytics.instance.trackEvent(
|
||||
{
|
||||
eventName: "MediaReceived",
|
||||
@@ -133,6 +135,8 @@ export class PosthogSpanProcessor implements SpanProcessor {
|
||||
videoReceived: videoReceived,
|
||||
maxJitter: maxJitter,
|
||||
maxPacketLoss: maxPacketLoss,
|
||||
peerConnections: peerConnections,
|
||||
percentageConcealedAudio: percentageConcealedAudio,
|
||||
},
|
||||
// Send instantly because the window might be closing
|
||||
{ send_instantly: true }
|
||||
|
||||
@@ -88,6 +88,7 @@ export const LoginPage: FC = () => {
|
||||
autoCapitalize="none"
|
||||
prefix="@"
|
||||
suffix={`:${Config.defaultServerName()}`}
|
||||
data-testid="login_username"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -96,6 +97,7 @@ export const LoginPage: FC = () => {
|
||||
ref={passwordRef}
|
||||
placeholder={t("Password")}
|
||||
label={t("Password")}
|
||||
data-testid="login_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
@@ -104,7 +106,11 @@ export const LoginPage: FC = () => {
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button type="submit" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
data-testid="login_login"
|
||||
>
|
||||
{loading ? t("Logging in…") : t("Login")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
|
||||
@@ -166,6 +166,7 @@ export const RegisterPage: FC = () => {
|
||||
autoCapitalize="none"
|
||||
prefix="@"
|
||||
suffix={`:${Config.defaultServerName()}`}
|
||||
data-testid="register_username"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -179,6 +180,7 @@ export const RegisterPage: FC = () => {
|
||||
value={password}
|
||||
placeholder={t("Password")}
|
||||
label={t("Password")}
|
||||
data-testid="register_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -193,6 +195,7 @@ export const RegisterPage: FC = () => {
|
||||
placeholder={t("Confirm password")}
|
||||
label={t("Confirm password")}
|
||||
ref={confirmPasswordRef}
|
||||
data-testid="register_confirm_password"
|
||||
/>
|
||||
</FieldRow>
|
||||
<Caption>
|
||||
@@ -217,7 +220,11 @@ export const RegisterPage: FC = () => {
|
||||
</FieldRow>
|
||||
)}
|
||||
<FieldRow>
|
||||
<Button type="submit" disabled={registering}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={registering}
|
||||
data-testid="register_register"
|
||||
>
|
||||
{registering ? t("Registering…") : t("Register")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
|
||||
@@ -43,7 +43,9 @@ export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
|
||||
<p>{t("This call already exists, would you like to join?")}</p>
|
||||
<FieldRow rightAlign className={styles.buttons}>
|
||||
<Button onPress={onClose}>{t("No")}</Button>
|
||||
<Button onPress={onJoin}>{t("Yes, join call")}</Button>
|
||||
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
|
||||
{t("Yes, join call")}
|
||||
</Button>
|
||||
</FieldRow>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
data-testid="home_callName"
|
||||
/>
|
||||
|
||||
<Button
|
||||
@@ -140,6 +141,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
|
||||
size="lg"
|
||||
className={styles.button}
|
||||
disabled={loading}
|
||||
data-testid="home_go"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Go")}
|
||||
</Button>
|
||||
|
||||
@@ -142,6 +142,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
data-testid="home_callName"
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow>
|
||||
@@ -152,6 +153,7 @@ export const UnauthenticatedView: FC = () => {
|
||||
placeholder={t("Display name")}
|
||||
type="text"
|
||||
required
|
||||
data-testid="home_displayName"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FieldRow>
|
||||
@@ -171,7 +173,12 @@ export const UnauthenticatedView: FC = () => {
|
||||
<ErrorMessage error={error} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
data-testid="home_go"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Go")}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
@@ -179,14 +186,14 @@ export const UnauthenticatedView: FC = () => {
|
||||
</main>
|
||||
<footer className={styles.footer}>
|
||||
<Body className={styles.mobileLoginLink}>
|
||||
<Link color="primary" to="/login">
|
||||
<Link color="primary" to="/login" data-testid="home_login">
|
||||
{t("Login to your account")}
|
||||
</Link>
|
||||
</Body>
|
||||
<Body>
|
||||
<Trans>
|
||||
Not registered yet?{" "}
|
||||
<Link color="primary" to="/register">
|
||||
<Link color="primary" to="/register" data-testid="home_register">
|
||||
Create an account
|
||||
</Link>
|
||||
</Trans>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg data-testid="videoTile_muted" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M0.20333 0.963373C0.474437 0.690007 0.913989 0.690007 1.1851 0.963373L11.5983 11.4633C11.8694 11.7367 11.8694 12.1799 11.5983 12.4533C11.3272 12.7267 10.8876 12.7267 10.6165 12.4533L0.20333 1.95332C-0.0677768 1.67995 -0.0677768 1.23674 0.20333 0.963373Z" fill="white"/>
|
||||
<path d="M0.418261 3.63429C0.226267 3.95219 0.115674 4.32557 0.115674 4.725V9.85832C0.115674 11.0181 1.0481 11.9583 2.19831 11.9583H8.65411L0.447396 3.66596C0.437225 3.65568 0.427513 3.64511 0.418261 3.63429Z" fill="white"/>
|
||||
<path d="M9.95036 4.725V8.33212L4.30219 2.625H7.86772C9.01793 2.625 9.95036 3.5652 9.95036 4.725Z" fill="white"/>
|
||||
|
||||
|
Before Width: | Height: | Size: 892 B After Width: | Height: | Size: 922 B |
@@ -47,7 +47,7 @@ export async function findDeviceByName(
|
||||
*
|
||||
* @return The available media devices
|
||||
*/
|
||||
export async function getDevices(): Promise<MediaDeviceInfo[]> {
|
||||
export async function getNamedDevices(): Promise<MediaDeviceInfo[]> {
|
||||
// First get the devices without their labels, to learn what kinds of streams
|
||||
// we can request
|
||||
let devices: MediaDeviceInfo[];
|
||||
|
||||
@@ -42,7 +42,7 @@ export class ElementCallOpenTelemetry {
|
||||
const config = Config.get();
|
||||
// we always enable opentelemetry in general. We only enable the OTLP
|
||||
// collector if a URL is defined (and in future if another setting is defined)
|
||||
// The posthog exporter is always enabled, posthog reporting is enabled or disabled
|
||||
// Posthog reporting is enabled or disabled
|
||||
// within the posthog code.
|
||||
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export function GroupCallLoader({
|
||||
if (loading) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading room…")}</h1>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
|
||||
import { useLocationNavigation } from "../useLocationNavigation";
|
||||
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
|
||||
import { useMediaHandler } from "../settings/useMediaHandler";
|
||||
import { findDeviceByName, getDevices } from "../media-utils";
|
||||
import { findDeviceByName, getNamedDevices } from "../media-utils";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -102,7 +102,7 @@ export function GroupCallView({
|
||||
// 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 devices = await getNamedDevices();
|
||||
|
||||
const { audioInput, videoInput } = ev.detail
|
||||
.data as unknown as JoinCallData;
|
||||
@@ -281,7 +281,7 @@ export function GroupCallView({
|
||||
} else if (isEmbedded) {
|
||||
return (
|
||||
<FullScreenView>
|
||||
<h1>{t("Loading room…")}</h1>
|
||||
<h1>{t("Loading…")}</h1>
|
||||
</FullScreenView>
|
||||
);
|
||||
} else {
|
||||
|
||||
@@ -407,11 +407,13 @@ export function InCallView({
|
||||
key="1"
|
||||
muted={microphoneMuted}
|
||||
onPress={toggleMicrophoneMuted}
|
||||
data-testid="incall_mute"
|
||||
/>,
|
||||
<VideoButton
|
||||
key="2"
|
||||
muted={localVideoMuted}
|
||||
onPress={toggleLocalVideoMuted}
|
||||
data-testid="incall_videomute"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -422,6 +424,7 @@ export function InCallView({
|
||||
key="3"
|
||||
enabled={isScreensharing}
|
||||
onPress={toggleScreensharing}
|
||||
data-testid="incall_screenshare"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -430,7 +433,9 @@ export function InCallView({
|
||||
}
|
||||
}
|
||||
|
||||
buttons.push(<HangupButton key="6" onPress={onLeave} />);
|
||||
buttons.push(
|
||||
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
|
||||
);
|
||||
footer = <div className={styles.footer}>{buttons}</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
|
||||
<CopyButton
|
||||
className={styles.copyButton}
|
||||
value={getRoomUrl(roomIdOrAlias)}
|
||||
data-testid="modal_inviteLink"
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
@@ -137,6 +137,7 @@ export function LobbyView({
|
||||
size="lg"
|
||||
disabled={state !== GroupCallState.LocalCallFeedInitialized}
|
||||
onPress={onEnter}
|
||||
data-testid="lobby_joinCall"
|
||||
>
|
||||
Join call now
|
||||
</Button>
|
||||
@@ -146,6 +147,7 @@ export function LobbyView({
|
||||
value={getRoomUrl(roomIdOrAlias)}
|
||||
className={styles.copyButton}
|
||||
copiedMessage={t("Call link copied")}
|
||||
data-testid="lobby_inviteLink"
|
||||
>
|
||||
Copy call link and join later
|
||||
</CopyButton>
|
||||
|
||||
@@ -74,6 +74,7 @@ export function RoomAuthView() {
|
||||
name="displayName"
|
||||
label={t("Display name")}
|
||||
placeholder={t("Display name")}
|
||||
data-testid="joincall_displayName"
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
@@ -90,7 +91,12 @@ export function RoomAuthView() {
|
||||
<ErrorMessage error={error} />
|
||||
</FieldRow>
|
||||
)}
|
||||
<Button type="submit" size="lg" disabled={loading}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
disabled={loading}
|
||||
data-testid="joincall_joincall"
|
||||
>
|
||||
{loading ? t("Loading…") : t("Join call now")}
|
||||
</Button>
|
||||
<div id={recaptchaId} />
|
||||
|
||||
@@ -77,7 +77,13 @@ export function VideoPreview({
|
||||
|
||||
return (
|
||||
<div className={styles.preview} ref={previewRef}>
|
||||
<video ref={videoRef} muted playsInline disablePictureInPicture />
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
data-testid="preview_video"
|
||||
/>
|
||||
{state === GroupCallState.LocalCallFeedUninitialized && (
|
||||
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
|
||||
{t("Camera/microphone permissions needed to join the call.")}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
|
||||
import { translatedError } from "../TranslatedError";
|
||||
import { widget } from "../widget";
|
||||
|
||||
const STATS_COLLECT_INTERVAL_TIME_MS = 30000;
|
||||
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
|
||||
|
||||
export interface GroupCallLoadState {
|
||||
loading: boolean;
|
||||
|
||||
@@ -106,6 +106,7 @@ export function ProfileSettingsTab({ client }: Props) {
|
||||
placeholder={t("Display name")}
|
||||
value={displayName}
|
||||
onChange={onChangeDisplayName}
|
||||
data-testid="profile_displayname"
|
||||
/>
|
||||
</FieldRow>
|
||||
{error && (
|
||||
|
||||
@@ -65,7 +65,9 @@ export const SettingsModal = (props: Props) => {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
} = useMediaHandler();
|
||||
useDeviceNames();
|
||||
|
||||
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
|
||||
const [showInspector, setShowInspector] = useShowInspector();
|
||||
|
||||
@@ -249,7 +249,7 @@ export function useSubmitRageshake(): {
|
||||
body.append(
|
||||
"file",
|
||||
gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()),
|
||||
"traces.json"
|
||||
"traces.json.gz"
|
||||
);
|
||||
|
||||
if (inspectorState) {
|
||||
|
||||
@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/ban-ts-comment */
|
||||
|
||||
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -25,20 +23,27 @@ import React, {
|
||||
useContext,
|
||||
createContext,
|
||||
ReactNode,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { useClient } from "../ClientContext";
|
||||
|
||||
import { getNamedDevices } from "../media-utils";
|
||||
|
||||
export interface MediaHandlerContextInterface {
|
||||
audioInput: string;
|
||||
audioInput: string | undefined;
|
||||
audioInputs: MediaDeviceInfo[];
|
||||
setAudioInput: (deviceId: string) => void;
|
||||
videoInput: string;
|
||||
videoInput: string | undefined;
|
||||
videoInputs: MediaDeviceInfo[];
|
||||
setVideoInput: (deviceId: string) => void;
|
||||
audioOutput: string;
|
||||
audioOutput: string | undefined;
|
||||
audioOutputs: MediaDeviceInfo[];
|
||||
setAudioOutput: (deviceId: string) => void;
|
||||
/**
|
||||
* A hook which requests for devices to be named. This requires media
|
||||
* permissions.
|
||||
*/
|
||||
useDeviceNames: () => void;
|
||||
}
|
||||
|
||||
const MediaHandlerContext =
|
||||
@@ -49,6 +54,7 @@ interface MediaPreferences {
|
||||
videoInput?: string;
|
||||
audioOutput?: string;
|
||||
}
|
||||
|
||||
function getMediaPreferences(): MediaPreferences {
|
||||
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
|
||||
|
||||
@@ -56,10 +62,10 @@ function getMediaPreferences(): MediaPreferences {
|
||||
try {
|
||||
return JSON.parse(mediaPreferences);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,9 +80,11 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function MediaHandlerProvider({ children }: Props): JSX.Element {
|
||||
const { client } = useClient();
|
||||
const [
|
||||
@@ -89,122 +97,109 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
|
||||
audioOutputs,
|
||||
},
|
||||
setState,
|
||||
] = useState(() => {
|
||||
const mediaHandler = client?.getMediaHandler();
|
||||
] = useState(() => ({
|
||||
audioInput: undefined as string | undefined,
|
||||
videoInput: undefined as string | undefined,
|
||||
audioOutput: undefined as string | undefined,
|
||||
audioInputs: [] as MediaDeviceInfo[],
|
||||
videoInputs: [] as MediaDeviceInfo[],
|
||||
audioOutputs: [] as MediaDeviceInfo[],
|
||||
}));
|
||||
|
||||
if (mediaHandler) {
|
||||
// A ref counting the number of components currently mounted that want
|
||||
// to know device names
|
||||
const numComponentsWantingNames = useRef(0);
|
||||
|
||||
const updateDevices = useCallback(
|
||||
async (client: MatrixClient, initial: boolean) => {
|
||||
// Only request device names if components actually want them, because it
|
||||
// could trigger an extra permission pop-up
|
||||
const devices = await (numComponentsWantingNames.current > 0
|
||||
? getNamedDevices()
|
||||
: navigator.mediaDevices.enumerateDevices());
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
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: [],
|
||||
};
|
||||
});
|
||||
const audioInputs = devices.filter((d) => d.kind === "audioinput");
|
||||
const videoInputs = devices.filter((d) => d.kind === "videoinput");
|
||||
const audioOutputs = devices.filter((d) => d.kind === "audiooutput");
|
||||
|
||||
const audioInput = (
|
||||
mediaPreferences.audioInput === undefined
|
||||
? audioInputs.at(0)
|
||||
: audioInputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.audioInput
|
||||
) ?? audioInputs.at(0)
|
||||
)?.deviceId;
|
||||
const videoInput = (
|
||||
mediaPreferences.videoInput === undefined
|
||||
? videoInputs.at(0)
|
||||
: videoInputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.videoInput
|
||||
) ?? videoInputs.at(0)
|
||||
)?.deviceId;
|
||||
const audioOutput =
|
||||
mediaPreferences.audioOutput === undefined
|
||||
? undefined
|
||||
: audioOutputs.find(
|
||||
(d) => d.deviceId === mediaPreferences.audioOutput
|
||||
)?.deviceId;
|
||||
|
||||
updateMediaPreferences({ audioInput, videoInput, audioOutput });
|
||||
setState({
|
||||
audioInput,
|
||||
videoInput,
|
||||
audioOutput,
|
||||
audioInputs,
|
||||
videoInputs,
|
||||
audioOutputs,
|
||||
});
|
||||
|
||||
if (
|
||||
initial ||
|
||||
audioInput !== mediaPreferences.audioInput ||
|
||||
videoInput !== mediaPreferences.videoInput
|
||||
) {
|
||||
client.getMediaHandler().setMediaInputs(audioInput, videoInput);
|
||||
}
|
||||
},
|
||||
[setState]
|
||||
);
|
||||
|
||||
const useDeviceNames = useCallback(() => {
|
||||
// This is a little weird from React's perspective as it looks like a
|
||||
// dynamic hook, but it works
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (client) {
|
||||
numComponentsWantingNames.current++;
|
||||
if (numComponentsWantingNames.current === 1)
|
||||
updateDevices(client, false);
|
||||
return () => void numComponentsWantingNames.current--;
|
||||
}
|
||||
}, []);
|
||||
}, [client, updateDevices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!client) return;
|
||||
if (client) {
|
||||
updateDevices(client, true);
|
||||
const onDeviceChange = () => updateDevices(client, false);
|
||||
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
|
||||
|
||||
const mediaHandler = client.getMediaHandler();
|
||||
|
||||
function updateDevices(): void {
|
||||
navigator.mediaDevices.enumerateDevices().then((devices) => {
|
||||
const mediaPreferences = getMediaPreferences();
|
||||
|
||||
const audioInputs = devices.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
onDeviceChange
|
||||
);
|
||||
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,
|
||||
});
|
||||
});
|
||||
client.getMediaHandler().stopAllStreams();
|
||||
};
|
||||
}
|
||||
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]);
|
||||
}, [client, updateDevices]);
|
||||
|
||||
const setAudioInput: (deviceId: string) => void = useCallback(
|
||||
(deviceId: string) => {
|
||||
updateMediaPreferences({ audioInput: deviceId });
|
||||
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
|
||||
client.getMediaHandler().setAudioInput(deviceId);
|
||||
client?.getMediaHandler().setAudioInput(deviceId);
|
||||
},
|
||||
[client]
|
||||
);
|
||||
@@ -235,6 +230,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
}),
|
||||
[
|
||||
audioInput,
|
||||
@@ -246,6 +242,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
|
||||
audioOutput,
|
||||
audioOutputs,
|
||||
setAudioOutput,
|
||||
useDeviceNames,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -245,6 +245,7 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
opacity: 0,
|
||||
scale: 0,
|
||||
shadow: 1,
|
||||
shadowSpread: 0,
|
||||
zIndex: 1,
|
||||
x,
|
||||
y,
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface TileSpring {
|
||||
opacity: number;
|
||||
scale: number;
|
||||
shadow: number;
|
||||
shadowSpread: number;
|
||||
zIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -172,8 +173,16 @@ function getOneOnOneLayoutTilePositions(
|
||||
const gridAspectRatio = gridWidth / gridHeight;
|
||||
|
||||
const smallPip = gridAspectRatio < 1 || gridWidth < 700;
|
||||
const pipWidth = smallPip ? 114 : 230;
|
||||
const pipHeight = smallPip ? 163 : 155;
|
||||
const maxPipWidth = smallPip ? 114 : 230;
|
||||
const maxPipHeight = smallPip ? 163 : 155;
|
||||
// Cap the PiP size at 1/3 the remote tile size, preserving aspect ratio
|
||||
const pipScaleFactor = Math.min(
|
||||
1,
|
||||
remotePosition.width / 3 / maxPipWidth,
|
||||
remotePosition.height / 3 / maxPipHeight
|
||||
);
|
||||
const pipWidth = maxPipWidth * pipScaleFactor;
|
||||
const pipHeight = maxPipHeight * pipScaleFactor;
|
||||
const pipGap = getPipGap(gridAspectRatio, gridWidth);
|
||||
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
@@ -892,6 +901,8 @@ export function VideoGrid({
|
||||
// Whether the tile positions were valid at the time of the previous
|
||||
// animation
|
||||
const tilePositionsWereValid = tilePositionsValid.current;
|
||||
const oneOnOneLayout =
|
||||
tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused);
|
||||
|
||||
return (tileIndex: number) => {
|
||||
const tile = tiles[tileIndex];
|
||||
@@ -911,12 +922,14 @@ export function VideoGrid({
|
||||
opacity: 1,
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
shadowSpread: 0,
|
||||
immediate: (key: string) =>
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "x" ||
|
||||
key === "y" ||
|
||||
key === "shadow",
|
||||
key === "shadow" ||
|
||||
key === "shadowSpread",
|
||||
from: {
|
||||
shadow: 0,
|
||||
scale: 0,
|
||||
@@ -974,10 +987,14 @@ export function VideoGrid({
|
||||
opacity: remove ? 0 : 1,
|
||||
zIndex: tilePosition.zIndex,
|
||||
shadow: 1,
|
||||
shadowSpread: oneOnOneLayout && tile.item.isLocal ? 1 : 0,
|
||||
from,
|
||||
reset,
|
||||
immediate: (key: string) =>
|
||||
disableAnimations || key === "zIndex" || key === "shadow",
|
||||
disableAnimations ||
|
||||
key === "zIndex" ||
|
||||
key === "shadow" ||
|
||||
key === "shadowSpread",
|
||||
// If we just stopped dragging a tile, give it time for the
|
||||
// animation to settle before pushing its z-index back down
|
||||
delay: (key: string) => (key === "zIndex" ? 500 : 0),
|
||||
|
||||
@@ -22,6 +22,8 @@ limitations under the License.
|
||||
height: var(--tileHeight);
|
||||
--tileRadius: 8px;
|
||||
border-radius: var(--tileRadius);
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
|
||||
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -45,7 +47,7 @@ limitations under the License.
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.videoTile.speaking::after {
|
||||
.videoTile::after {
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
@@ -54,6 +56,12 @@ limitations under the License.
|
||||
content: "";
|
||||
border-radius: var(--tileRadius);
|
||||
box-shadow: inset 0 0 0 4px var(--accent) !important;
|
||||
opacity: 0;
|
||||
transition: opacity ease 0.15s;
|
||||
}
|
||||
|
||||
.videoTile.speaking::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.videoTile.maximised {
|
||||
@@ -83,6 +91,12 @@ limitations under the License.
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.infoBubble > svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -126,10 +140,6 @@ limitations under the License.
|
||||
bottom: 16px;
|
||||
}
|
||||
|
||||
.memberName > * {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.memberName > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
|
||||
import { AudioButton, FullscreenButton } from "../button/Button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
|
||||
@@ -47,6 +47,7 @@ interface Props {
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
@@ -79,6 +80,7 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
@@ -141,9 +143,6 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
boxShadow: shadow?.to(
|
||||
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
|
||||
),
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
@@ -152,8 +151,11 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
// but React's types say no
|
||||
"--tileWidth": width?.to((w) => `${w}px`),
|
||||
"--tileHeight": height?.to((h) => `${h}px`),
|
||||
"--tileShadow": shadow?.to((s) => `${s}px`),
|
||||
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
|
||||
}}
|
||||
ref={ref as ForwardedRef<HTMLDivElement>}
|
||||
data-testid="videoTile"
|
||||
{...rest}
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
@@ -177,13 +179,19 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
Mute state is currently sent over to-device messages, which
|
||||
aren't quite real-time, so this is an important kludge to make
|
||||
sure no one appears muted when they've clearly begun talking. */
|
||||
audioMuted && !videoMuted && !speaking && <MicMutedIcon />
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
{videoMuted && <VideoMutedIcon />}
|
||||
<span title={caption}>{caption}</span>
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video ref={mediaRef} playsInline disablePictureInPicture />
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ interface Props {
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
|
||||
Reference in New Issue
Block a user