Refactor/redesign video tiles

This commit is contained in:
Robin
2023-12-01 17:43:09 -05:00
parent 0ab3e0e090
commit b2bc8edcc1
28 changed files with 1086 additions and 709 deletions

View File

@@ -16,6 +16,8 @@ limitations under the License.
import {
ComponentProps,
ForwardedRef,
ReactNode,
forwardRef,
useCallback,
useEffect,
@@ -24,11 +26,9 @@ import {
import { animated } from "@react-spring/web";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { LocalParticipant, RemoteParticipant, Track } from "livekit-client";
import {
ConnectionQualityIndicator,
TrackReferenceOrPlaceholder,
VideoTrack,
useMediaTrack,
} from "@livekit/components-react";
import {
RoomMember,
@@ -37,196 +37,121 @@ import {
import MicOnSolidIcon from "@vector-im/compound-design-tokens/icons/mic-on-solid.svg?react";
import MicOffSolidIcon from "@vector-im/compound-design-tokens/icons/mic-off-solid.svg?react";
import ErrorIcon from "@vector-im/compound-design-tokens/icons/error.svg?react";
import { Text, Tooltip } from "@vector-im/compound-web";
import MicOffOutlineIcon from "@vector-im/compound-design-tokens/icons/mic-off-outline.svg?react";
import OverflowHorizontalIcon from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg?react";
import VolumeOnIcon from "@vector-im/compound-design-tokens/icons/volume-on.svg?react";
import VolumeOffIcon from "@vector-im/compound-design-tokens/icons/volume-off.svg?react";
import UserProfileIcon from "@vector-im/compound-design-tokens/icons/user-profile.svg?react";
import ExpandIcon from "@vector-im/compound-design-tokens/icons/expand.svg?react";
import CollapseIcon from "@vector-im/compound-design-tokens/icons/collapse.svg?react";
import {
Text,
Tooltip,
ContextMenu,
MenuItem,
ToggleMenuItem,
Menu,
} from "@vector-im/compound-web";
import { useStateObservable } from "@react-rxjs/core";
import { Avatar } from "../Avatar";
import styles from "./VideoTile.module.css";
import { useReactiveState } from "../useReactiveState";
import { AudioButton, FullscreenButton } from "../button/Button";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { MatrixInfo } from "../room/VideoPreview";
import {
ScreenShareTileViewModel,
TileViewModel,
UserMediaTileViewModel,
} from "../state/TileViewModel";
import { subscribe } from "../state/subscribe";
import { useMergedRefs } from "../useMergedRefs";
import { Slider } from "../Slider";
export interface ItemData {
id: string;
member?: RoomMember;
sfuParticipant: LocalParticipant | RemoteParticipant;
content: TileContent;
}
export enum TileContent {
UserMedia = "user-media",
ScreenShare = "screen-share",
}
interface Props {
vm: TileViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
// TODO: Refactor these props.
targetWidth: number;
targetHeight: number;
interface TileProps {
tileRef?: ForwardedRef<HTMLDivElement>;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean;
showConnectionStats: boolean;
// TODO: This dependency in particular should probably not be here. We can fix
// this with a view model.
matrixInfo: MatrixInfo;
targetWidth: number;
targetHeight: number;
video: TrackReferenceOrPlaceholder;
member: RoomMember | undefined;
videoEnabled: boolean;
maximised: boolean;
unencryptedWarning: boolean;
nameTagLeadingIcon?: ReactNode;
nameTag: string;
displayName: string;
primaryButton: ReactNode;
secondaryButton?: ReactNode;
[k: string]: unknown;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
const Tile = forwardRef<HTMLDivElement, TileProps>(
(
{
vm,
maximised,
fullscreen,
onToggleFullscreen,
tileRef = null,
className,
style,
targetWidth,
targetHeight,
showSpeakingIndicator,
showConnectionStats,
matrixInfo,
video,
member,
videoEnabled,
maximised,
unencryptedWarning,
nameTagLeadingIcon,
nameTag,
displayName,
primaryButton,
secondaryButton,
...props
},
tileRef,
ref,
) => {
const { t } = useTranslation();
const { id, sfuParticipant, member } = vm;
// Handle display name changes.
const [displayName, setDisplayName] = useReactiveState(
() => member?.rawDisplayName ?? "[👻]",
[member],
);
useEffect(() => {
if (member) {
const updateName = (): void => {
setDisplayName(member.rawDisplayName);
};
member!.on(RoomMemberEvent.Name, updateName);
return (): void => {
member!.removeListener(RoomMemberEvent.Name, updateName);
};
}
}, [member, setDisplayName]);
const audioInfo = useMediaTrack(
vm instanceof UserMediaTileViewModel
? Track.Source.Microphone
: Track.Source.ScreenShareAudio,
sfuParticipant,
);
const videoInfo = useMediaTrack(
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare,
sfuParticipant,
);
const muted = audioInfo.isMuted !== false;
const encrypted =
audioInfo.publication?.isEncrypted !== false &&
videoInfo.publication?.isEncrypted !== false;
const MicIcon = muted ? MicOffSolidIcon : MicOnSolidIcon;
const onFullscreen = useCallback(() => {
onToggleFullscreen(id);
}, [id, onToggleFullscreen]);
const [videoTileSettingsModalOpen, setVideoTileSettingsModalOpen] =
useState(false);
const openVideoTileSettingsModal = useCallback(
() => setVideoTileSettingsModalOpen(true),
[setVideoTileSettingsModalOpen],
);
const closeVideoTileSettingsModal = useCallback(
() => setVideoTileSettingsModalOpen(false),
[setVideoTileSettingsModalOpen],
);
const toolbarButtons: JSX.Element[] = [];
if (!sfuParticipant.isLocal) {
toolbarButtons.push(
<AudioButton
key="localVolume"
className={styles.button}
volume={(sfuParticipant as RemoteParticipant).getVolume() ?? 0}
onPress={openVideoTileSettingsModal}
/>,
);
if (vm instanceof ScreenShareTileViewModel) {
toolbarButtons.push(
<FullscreenButton
key="fullscreen"
className={styles.button}
fullscreen={fullscreen}
onPress={onFullscreen}
/>,
);
}
}
const mergedRef = useMergedRefs(tileRef, ref);
return (
<animated.div
className={classNames(styles.videoTile, className, {
[styles.isLocal]: sfuParticipant.isLocal,
[styles.speaking]:
sfuParticipant.isSpeaking &&
vm instanceof UserMediaTileViewModel &&
showSpeakingIndicator,
[styles.screenshare]: vm instanceof ScreenShareTileViewModel,
[styles.maximised]: maximised,
[styles.videoMuted]: !videoEnabled,
})}
style={style}
ref={tileRef}
ref={mergedRef}
data-testid="videoTile"
{...props}
>
{toolbarButtons.length > 0 && (!maximised || fullscreen) && (
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
)}
{vm instanceof UserMediaTileViewModel &&
!sfuParticipant.isCameraEnabled && (
<>
<div className={styles.videoMutedOverlay} />
<Avatar
key={member?.userId}
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
</>
)}
{vm instanceof ScreenShareTileViewModel ? (
<div className={styles.presenterLabel}>
<span>{t("video_tile.presenter_label", { displayName })}</span>
</div>
) : (
<div className={styles.nameTag}>
<MicIcon
width={20}
height={20}
aria-label={muted ? t("microphone_off") : t("microphone_on")}
data-muted={muted}
className={styles.muteIcon}
<div className={styles.bg}>
<Avatar
id={member?.userId ?? displayName}
name={displayName}
size={Math.round(Math.min(targetWidth, targetHeight) / 2)}
src={member?.getMxcAvatarUrl()}
className={styles.avatar}
/>
{video.publication !== undefined && (
<VideoTrack
trackRef={video}
// There's no reason for this to be focusable
tabIndex={-1}
// React supports the disablePictureInPicture attribute, but Firefox
// only recognizes a value of "true", whereas React sets it to the empty
// string. So we need to bypass React and set it specifically to "true".
// https://bugzilla.mozilla.org/show_bug.cgi?id=1865748
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
disablepictureinpicture="true"
/>
)}
</div>
<div className={styles.fg}>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium">
{sfuParticipant.isLocal
? t("video_tile.sfu_participant_local")
: displayName}
{nameTag}
</Text>
{matrixInfo.roomEncrypted && !encrypted && (
{unencryptedWarning && (
<Tooltip label={t("common.unencrypted")} side="bottom">
<ErrorIcon
width={20}
@@ -242,42 +167,307 @@ export const VideoTile = forwardRef<HTMLDivElement, Props>(
/>
</Tooltip>
)}
{showConnectionStats && (
<ConnectionQualityIndicator participant={sfuParticipant} />
)}
</div>
)}
<VideoTrack
participant={sfuParticipant}
source={
vm instanceof UserMediaTileViewModel
? Track.Source.Camera
: Track.Source.ScreenShare
}
// There's no reason for this to be focusable
tabIndex={-1}
// React supports the disablePictureInPicture attribute, but Firefox
// only recognizes a value of "true", whereas React sets it to the empty
// string. So we need to bypass React and set it specifically to "true".
// https://bugzilla.mozilla.org/show_bug.cgi?id=1865748
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
// eslint-disable-next-line react/no-unknown-property
disablepictureinpicture="true"
/>
{!maximised && sfuParticipant instanceof RemoteParticipant && (
<VideoTileSettingsModal
participant={sfuParticipant}
media={
vm instanceof UserMediaTileViewModel
? "user media"
: "screen share"
}
open={videoTileSettingsModalOpen}
onDismiss={closeVideoTileSettingsModal}
/>
)}
{primaryButton}
{secondaryButton}
</div>
</animated.div>
);
},
);
interface UserMediaTileProps {
vm: UserMediaTileViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
onOpenProfile: () => void;
showSpeakingIndicator: boolean;
}
const UserMediaTile = subscribe<UserMediaTileProps, HTMLDivElement>(
(
{
vm,
className,
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
onOpenProfile,
showSpeakingIndicator,
},
ref,
) => {
const { t } = useTranslation();
const video = useStateObservable(vm.video);
const audioEnabled = useStateObservable(vm.audioEnabled);
const videoEnabled = useStateObservable(vm.videoEnabled);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const mirror = useStateObservable(vm.mirror);
const speaking = useStateObservable(vm.speaking);
const locallyMuted = useStateObservable(vm.locallyMuted);
const localVolume = useStateObservable(vm.localVolume);
const onChangeMute = useCallback(() => vm.toggleLocallyMuted(), [vm]);
const onSelectMute = useCallback((e: Event) => e.preventDefault(), []);
const onChangeLocalVolume = useCallback(
(v: number) => vm.setLocalVolume(v),
[vm],
);
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
const VolumeIcon = locallyMuted ? VolumeOffIcon : VolumeOnIcon;
const [menuOpen, setMenuOpen] = useState(false);
const menu = vm.local ? (
<>
<MenuItem
Icon={UserProfileIcon}
label={t("common.profile")}
onSelect={onOpenProfile}
/>
</>
) : (
<>
<ToggleMenuItem
Icon={MicOffOutlineIcon}
label={t("video_tile.mute_for_me")}
checked={locallyMuted}
onChange={onChangeMute}
onSelect={onSelectMute}
/>
{/* TODO: Figure out how to make this slider keyboard accessible */}
<MenuItem as="div" Icon={VolumeIcon} label={null} onSelect={null}>
<Slider
className={styles.volumeSlider}
label={t("video_tile.volume")}
value={localVolume}
onValueChange={onChangeLocalVolume}
min={0.1}
max={1}
step={0.01}
disabled={locallyMuted}
/>
</MenuItem>
</>
);
const tile = (
<Tile
tileRef={ref}
className={classNames(className, {
[styles.mirror]: mirror,
[styles.speaking]: showSpeakingIndicator && speaking,
})}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
member={vm.member}
videoEnabled={videoEnabled}
maximised={maximised}
unencryptedWarning={unencryptedWarning}
nameTagLeadingIcon={
<MicIcon
width={20}
height={20}
aria-label={audioEnabled ? t("microphone_on") : t("microphone_off")}
data-muted={!audioEnabled}
className={styles.muteIcon}
/>
}
nameTag={nameTag}
displayName={displayName}
primaryButton={
<Menu
open={menuOpen}
onOpenChange={setMenuOpen}
title={nameTag}
trigger={
<button aria-label={t("common.options")}>
<OverflowHorizontalIcon aria-hidden width={20} height={20} />
</button>
}
side="left"
align="start"
>
{menu}
</Menu>
}
/>
);
return (
<ContextMenu title={nameTag} trigger={tile}>
{menu}
</ContextMenu>
);
},
);
interface ScreenShareTileProps {
vm: ScreenShareTileViewModel;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
targetWidth: number;
targetHeight: number;
nameTag: string;
displayName: string;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
}
const ScreenShareTile = subscribe<ScreenShareTileProps, HTMLDivElement>(
(
{
vm,
className,
style,
targetWidth,
targetHeight,
nameTag,
displayName,
maximised,
fullscreen,
onToggleFullscreen,
},
ref,
) => {
const { t } = useTranslation();
const video = useStateObservable(vm.video);
const unencryptedWarning = useStateObservable(vm.unencryptedWarning);
const onClickFullScreen = useCallback(
() => onToggleFullscreen(vm.id),
[onToggleFullscreen, vm],
);
const FullScreenIcon = fullscreen ? CollapseIcon : ExpandIcon;
return (
<Tile
ref={ref}
className={classNames(className, styles.screenshare)}
style={style}
targetWidth={targetWidth}
targetHeight={targetHeight}
video={video}
member={vm.member}
videoEnabled={true}
maximised={maximised}
unencryptedWarning={unencryptedWarning}
nameTag={nameTag}
displayName={displayName}
primaryButton={
!vm.local && (
<button
aria-label={
fullscreen
? t("video_tile.full_screen")
: t("video_tile.exit_full_screen")
}
onClick={onClickFullScreen}
>
<FullScreenIcon aria-hidden width={20} height={20} />
</button>
)
}
/>
);
},
);
interface Props {
vm: TileViewModel;
maximised: boolean;
fullscreen: boolean;
onToggleFullscreen: (itemId: string) => void;
onOpenProfile: () => void;
targetWidth: number;
targetHeight: number;
className?: string;
style?: ComponentProps<typeof animated.div>["style"];
showSpeakingIndicator: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
(
{
vm,
maximised,
fullscreen,
onToggleFullscreen,
onOpenProfile,
className,
style,
targetWidth,
targetHeight,
showSpeakingIndicator,
},
ref,
) => {
const { t } = useTranslation();
// Handle display name changes.
// TODO: Move this into the view model
const [displayName, setDisplayName] = useReactiveState(
() => vm.member?.rawDisplayName ?? "[👻]",
[vm.member],
);
useEffect(() => {
if (vm.member) {
const updateName = (): void => {
setDisplayName(vm.member!.rawDisplayName);
};
vm.member!.on(RoomMemberEvent.Name, updateName);
return (): void => {
vm.member!.removeListener(RoomMemberEvent.Name, updateName);
};
}
}, [vm.member, setDisplayName]);
const nameTag = vm.local
? t("video_tile.sfu_participant_local")
: displayName;
if (vm instanceof UserMediaTileViewModel) {
return (
<UserMediaTile
ref={ref}
className={className}
style={style}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
onOpenProfile={onOpenProfile}
showSpeakingIndicator={showSpeakingIndicator}
/>
);
} else {
return (
<ScreenShareTile
ref={ref}
className={className}
style={style}
vm={vm}
targetWidth={targetWidth}
targetHeight={targetHeight}
nameTag={nameTag}
displayName={displayName}
maximised={maximised}
fullscreen={fullscreen}
onToggleFullscreen={onToggleFullscreen}
/>
);
}
},
);