Inform user that their camera is starting in Lobby (#2869)
* Inform user that their camera is starting Instead of just showing a grey box. * Review feedback * Show spinner from design suggestion * useMemo * Lint * Lint * Feedback from review * Use colour that actually exists * Refactor into Avatar superclass * . * Remove size limit behaviour * Add VideoPreview tests
This commit is contained in:
@@ -195,6 +195,7 @@
|
|||||||
"version": "{{productName}} version: {{version}}",
|
"version": "{{productName}} version: {{version}}",
|
||||||
"video_tile": {
|
"video_tile": {
|
||||||
"always_show": "Always show",
|
"always_show": "Always show",
|
||||||
|
"camera_starting": "Video loading...",
|
||||||
"change_fit_contain": "Fit to frame",
|
"change_fit_contain": "Fit to frame",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const sizes = new Map([
|
|||||||
[Size.XL, 90],
|
[Size.XL, 90],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
interface Props {
|
export interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ video.mirror {
|
|||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.preview .cameraStarting {
|
||||||
|
position: absolute;
|
||||||
|
top: var(--cpd-space-10x);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--cpd-color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
.avatarContainer {
|
.avatarContainer {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
73
src/room/VideoPreview.test.tsx
Normal file
73
src/room/VideoPreview.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, describe, it, vi, beforeAll } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { type MatrixInfo, VideoPreview } from "./VideoPreview";
|
||||||
|
import { type MuteStates } from "./MuteStates";
|
||||||
|
import { E2eeType } from "../e2ee/e2eeType";
|
||||||
|
|
||||||
|
function mockMuteStates({ audio = true, video = true } = {}): MuteStates {
|
||||||
|
return {
|
||||||
|
audio: { enabled: audio, setEnabled: vi.fn() },
|
||||||
|
video: { enabled: video, setEnabled: vi.fn() },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VideoPreview", () => {
|
||||||
|
const matrixInfo: MatrixInfo = {
|
||||||
|
userId: "@a:example.org",
|
||||||
|
displayName: "Alice",
|
||||||
|
avatarUrl: "",
|
||||||
|
roomId: "",
|
||||||
|
roomName: "",
|
||||||
|
e2eeSystem: { kind: E2eeType.NONE },
|
||||||
|
roomAlias: null,
|
||||||
|
roomAvatar: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
window.ResizeObserver = class ResizeObserver {
|
||||||
|
public observe(): void {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
public unobserve(): void {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
public disconnect(): void {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows avatar with video disabled", () => {
|
||||||
|
const { queryByRole } = render(
|
||||||
|
<VideoPreview
|
||||||
|
matrixInfo={matrixInfo}
|
||||||
|
muteStates={mockMuteStates({ video: false })}
|
||||||
|
videoTrack={null}
|
||||||
|
children={<></>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(queryByRole("img", { name: "@a:example.org" })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading status with video enabled but no track", () => {
|
||||||
|
const { queryByRole } = render(
|
||||||
|
<VideoPreview
|
||||||
|
matrixInfo={matrixInfo}
|
||||||
|
muteStates={mockMuteStates({ video: true })}
|
||||||
|
videoTrack={null}
|
||||||
|
children={<></>}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(queryByRole("status")).toHaveTextContent(
|
||||||
|
"video_tile.camera_starting",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
|
|||||||
Please see LICENSE in the repository root for full details.
|
Please see LICENSE in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, type FC, type ReactNode } from "react";
|
import { useEffect, useMemo, useRef, type FC, type ReactNode } from "react";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Avatar } from "../Avatar";
|
import { TileAvatar } from "../tile/TileAvatar";
|
||||||
import styles from "./VideoPreview.module.css";
|
import styles from "./VideoPreview.module.css";
|
||||||
import { type MuteStates } from "./MuteStates";
|
import { type MuteStates } from "./MuteStates";
|
||||||
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
|
||||||
@@ -39,6 +40,7 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
videoTrack,
|
videoTrack,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [previewRef, previewBounds] = useMeasure();
|
const [previewRef, previewBounds] = useMeasure();
|
||||||
|
|
||||||
const videoEl = useRef<HTMLVideoElement | null>(null);
|
const videoEl = useRef<HTMLVideoElement | null>(null);
|
||||||
@@ -53,6 +55,11 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
};
|
};
|
||||||
}, [videoTrack]);
|
}, [videoTrack]);
|
||||||
|
|
||||||
|
const cameraIsStarting = useMemo(
|
||||||
|
() => muteStates.video.enabled && !videoTrack,
|
||||||
|
[muteStates.video.enabled, videoTrack],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.preview)} ref={previewRef}>
|
<div className={classNames(styles.preview)} ref={previewRef}>
|
||||||
<video
|
<video
|
||||||
@@ -69,15 +76,23 @@ export const VideoPreview: FC<Props> = ({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
/>
|
/>
|
||||||
{!muteStates.video.enabled && (
|
{(!muteStates.video.enabled || cameraIsStarting) && (
|
||||||
<div className={styles.avatarContainer}>
|
<>
|
||||||
<Avatar
|
<div className={styles.avatarContainer}>
|
||||||
id={matrixInfo.userId}
|
{cameraIsStarting && (
|
||||||
name={matrixInfo.displayName}
|
<div className={styles.cameraStarting} role="status">
|
||||||
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
{t("video_tile.camera_starting")}
|
||||||
src={matrixInfo.avatarUrl}
|
</div>
|
||||||
/>
|
)}
|
||||||
</div>
|
<TileAvatar
|
||||||
|
id={matrixInfo.userId}
|
||||||
|
name={matrixInfo.displayName}
|
||||||
|
size={Math.min(previewBounds.width, previewBounds.height) / 2}
|
||||||
|
src={matrixInfo.avatarUrl}
|
||||||
|
loading={cameraIsStarting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<div className={styles.buttonBar}>{children}</div>
|
<div className={styles.buttonBar}>{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
20
src/tile/TileAvatar.module.css
Normal file
20
src/tile/TileAvatar.module.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0.5;
|
||||||
|
/* TODO: make this --cpd-color-fg-primary when available. */
|
||||||
|
color: var(--cpd-color-text-primary);
|
||||||
|
}
|
||||||
27
src/tile/TileAvatar.test.tsx
Normal file
27
src/tile/TileAvatar.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { expect, describe, it } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
|
||||||
|
import { TileAvatar } from "./TileAvatar";
|
||||||
|
|
||||||
|
describe("TileAvatar", () => {
|
||||||
|
it("should show loading spinner when loading", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={true} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".loading")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not show loading spinner when not loading", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TileAvatar id="@a:example.org" name="Alice" size={96} loading={false} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".loading")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
30
src/tile/TileAvatar.tsx
Normal file
30
src/tile/TileAvatar.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
Copyright 2024 New Vector Ltd.
|
||||||
|
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
Please see LICENSE in the repository root for full details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { type FC } from "react";
|
||||||
|
import { InlineSpinner } from "@vector-im/compound-web";
|
||||||
|
|
||||||
|
import styles from "./TileAvatar.module.css";
|
||||||
|
import { Avatar, type Props as AvatarProps } from "../Avatar";
|
||||||
|
|
||||||
|
interface Props extends AvatarProps {
|
||||||
|
size: number;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TileAvatar: FC<Props> = ({ size, loading, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{loading && (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<InlineSpinner size={size / 3} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Avatar size={size} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user