Skip to content

Commit

Permalink
Inform user that their camera is starting in Lobby (#2869)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
hughns authored Dec 18, 2024
1 parent 19d0f84 commit ba5da7e
Show file tree
Hide file tree
Showing 8 changed files with 189 additions and 12 deletions.
1 change: 1 addition & 0 deletions locales/en/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@
"version": "{{productName}} version: {{version}}",
"video_tile": {
"always_show": "Always show",
"camera_starting": "Video loading...",
"change_fit_contain": "Fit to frame",
"collapse": "Collapse",
"expand": "Expand",
Expand Down
2 changes: 1 addition & 1 deletion src/Avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export const sizes = new Map([
[Size.XL, 90],
]);

interface Props {
export interface Props {
id: string;
name: string;
className?: string;
Expand Down
11 changes: 11 additions & 0 deletions src/room/VideoPreview.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ video.mirror {
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 {
position: absolute;
top: 0;
Expand Down
73 changes: 73 additions & 0 deletions src/room/VideoPreview.test.tsx
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
37 changes: 26 additions & 11 deletions src/room/VideoPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only
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 { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
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 { type MuteStates } from "./MuteStates";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
Expand Down Expand Up @@ -39,6 +40,7 @@ export const VideoPreview: FC<Props> = ({
videoTrack,
children,
}) => {
const { t } = useTranslation();
const [previewRef, previewBounds] = useMeasure();

const videoEl = useRef<HTMLVideoElement | null>(null);
Expand All @@ -53,6 +55,11 @@ export const VideoPreview: FC<Props> = ({
};
}, [videoTrack]);

const cameraIsStarting = useMemo(
() => muteStates.video.enabled && !videoTrack,
[muteStates.video.enabled, videoTrack],
);

return (
<div className={classNames(styles.preview)} ref={previewRef}>
<video
Expand All @@ -69,15 +76,23 @@ export const VideoPreview: FC<Props> = ({
tabIndex={-1}
disablePictureInPicture
/>
{!muteStates.video.enabled && (
<div className={styles.avatarContainer}>
<Avatar
id={matrixInfo.userId}
name={matrixInfo.displayName}
size={Math.min(previewBounds.width, previewBounds.height) / 2}
src={matrixInfo.avatarUrl}
/>
</div>
{(!muteStates.video.enabled || cameraIsStarting) && (
<>
<div className={styles.avatarContainer}>
{cameraIsStarting && (
<div className={styles.cameraStarting} role="status">
{t("video_tile.camera_starting")}
</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>
Expand Down
20 changes: 20 additions & 0 deletions src/tile/TileAvatar.module.css
Original file line number Diff line number Diff line change
@@ -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 changes: 27 additions & 0 deletions src/tile/TileAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 30 additions & 0 deletions src/tile/TileAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
};

0 comments on commit ba5da7e

Please sign in to comment.