Refactor/redesign video tiles
This commit is contained in:
@@ -112,7 +112,7 @@ export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
|
||||
return { layout: layoutRef.current, setLayout };
|
||||
}
|
||||
|
||||
const GAP = 8;
|
||||
const GAP = 20;
|
||||
|
||||
function useIsMounted(): MutableRefObject<boolean> {
|
||||
const isMountedRef = useRef<boolean>(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-2023 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.
|
||||
@@ -16,26 +16,63 @@ limitations under the License.
|
||||
|
||||
.videoTile {
|
||||
position: absolute;
|
||||
contain: strict;
|
||||
top: 0;
|
||||
container-name: videoTile;
|
||||
container-type: size;
|
||||
border-radius: var(--cpd-space-4x);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
outline: 2px solid rgba(0, 0, 0, 0);
|
||||
transition:
|
||||
outline-radius ease 0.15s,
|
||||
outline-color ease 0.15s;
|
||||
transition: outline-color ease 0.15s;
|
||||
outline: var(--cpd-border-width-2) solid rgb(0 0 0 / 0);
|
||||
}
|
||||
|
||||
.videoTile * {
|
||||
user-select: none;
|
||||
/* Use a pseudo-element to create the expressive speaking border, since CSS
|
||||
borders don't support gradients */
|
||||
.videoTile::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1; /* Put it below the outline */
|
||||
opacity: 0; /* Hidden unless speaking */
|
||||
transition: opacity ease 0.15s;
|
||||
inset: calc(-1 * var(--cpd-border-width-4));
|
||||
border-radius: var(--cpd-space-5x);
|
||||
background: linear-gradient(
|
||||
119deg,
|
||||
rgba(13, 92, 189, 0.7) 0%,
|
||||
rgba(13, 189, 168, 0.7) 100%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(13, 92, 189, 0.9) 0%,
|
||||
rgba(13, 189, 168, 0.9) 100%
|
||||
);
|
||||
background-blend-mode: overlay, normal;
|
||||
}
|
||||
|
||||
.videoTile.speaking {
|
||||
/* !important because speaking border should take priority over hover */
|
||||
outline: var(--cpd-border-width-1) solid var(--cpd-color-bg-canvas-default) !important;
|
||||
}
|
||||
|
||||
.videoTile.speaking::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.videoTile:hover {
|
||||
outline: var(--cpd-border-width-2) solid
|
||||
var(--cpd-color-border-interactive-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.videoTile.maximised {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.videoTile video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
object-fit: cover;
|
||||
background-color: var(--cpd-color-bg-subtle-primary);
|
||||
/* This transform is a no-op, but it forces Firefox to use a different
|
||||
@@ -44,36 +81,69 @@ limitations under the License.
|
||||
transform: translate(0);
|
||||
}
|
||||
|
||||
.videoTile.isLocal:not(.screenshare) video {
|
||||
.videoTile.mirror video {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.videoTile.speaking {
|
||||
/* !important because speaking border should take priority over hover */
|
||||
outline: 4px solid var(--cpd-color-border-accent) !important;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.videoTile:hover {
|
||||
outline: 2px solid var(--cpd-color-gray-1400);
|
||||
}
|
||||
}
|
||||
|
||||
.videoTile.maximised {
|
||||
position: relative;
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.videoTile.screenshare > video {
|
||||
.videoTile.screenshare video {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.nameTag {
|
||||
.videoTile.videoMuted video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.bg {
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
inline-size: 100%;
|
||||
block-size: 100%;
|
||||
border-radius: inherit;
|
||||
contain: strict;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset-inline-start: var(--cpd-space-1x);
|
||||
inset-block-end: var(--cpd-space-1x);
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.videoTile.videoMuted .avatar {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
/* CSS makes us put a condition here, even though all we want to do is
|
||||
unconditionally select the container so we can use cqmin units */
|
||||
@container videoTile (width > 0) {
|
||||
.avatar {
|
||||
/* Half of the smallest dimension of the tile */
|
||||
inline-size: 50cqmin;
|
||||
block-size: 50cqmin;
|
||||
}
|
||||
}
|
||||
|
||||
.avatar > img {
|
||||
/* To make avatars scale smoothly with their tiles during animations, we
|
||||
override the styles set on the element */
|
||||
inline-size: 100% !important;
|
||||
block-size: 100% !important;
|
||||
}
|
||||
|
||||
.fg {
|
||||
position: absolute;
|
||||
inset: var(--cpd-space-1x);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: 1fr auto;
|
||||
grid-template-areas: ". button2" "nameTag button1";
|
||||
gap: var(--cpd-space-1x);
|
||||
place-items: start;
|
||||
}
|
||||
|
||||
.nameTag {
|
||||
grid-area: nameTag;
|
||||
padding: var(--cpd-space-1x);
|
||||
padding-block: var(--cpd-space-1x);
|
||||
color: var(--cpd-color-text-primary);
|
||||
@@ -82,10 +152,10 @@ limitations under the License.
|
||||
align-items: center;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
user-select: none;
|
||||
max-width: calc(100% - 48px);
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
box-sizing: border-box;
|
||||
max-inline-size: 100%;
|
||||
}
|
||||
|
||||
.nameTag > svg {
|
||||
@@ -111,94 +181,56 @@ limitations under the License.
|
||||
color: var(--cpd-color-icon-critical-primary);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
width: 100%;
|
||||
height: 42px;
|
||||
|
||||
color: var(--cpd-color-text-primary);
|
||||
background-color: var(--stopgap-background-85);
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
|
||||
.fg > button {
|
||||
appearance: none;
|
||||
border: none;
|
||||
border-radius: var(--cpd-radius-pill-effect);
|
||||
padding: var(--cpd-space-1x);
|
||||
background: var(--cpd-color-bg-action-primary-rest);
|
||||
box-shadow: var(--small-drop-shadow);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity ease 0.15s;
|
||||
}
|
||||
|
||||
.toolbar:not(:hover) {
|
||||
opacity: 0;
|
||||
.fg > button:focus-visible,
|
||||
.fg > :focus-visible ~ button,
|
||||
.fg > button[data-enabled="true"],
|
||||
.fg > button[data-state="open"] {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.toolbar:hover + .presenterLabel {
|
||||
top: calc(42px + 20px); /* toolbar + margin */
|
||||
}
|
||||
@media (hover) {
|
||||
.fg:hover > button {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.videoMutedOverlay {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--cpd-color-bg-subtle-secondary);
|
||||
}
|
||||
|
||||
.presenterLabel {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--stopgap-background-85);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
font-weight: normal;
|
||||
font-size: var(--font-size-caption);
|
||||
line-height: var(--font-size-body);
|
||||
}
|
||||
|
||||
.screensharePIP {
|
||||
bottom: 8px;
|
||||
right: 8px;
|
||||
width: 25%;
|
||||
max-width: 360px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.debugInfo {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* CSS makes us put a condition here, even though all we want to do is
|
||||
unconditionally select the container so we can use cqmin units */
|
||||
@container videoTile (width > 0) {
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* To make avatars scale smoothly with their tiles during animations, we
|
||||
override the styles set on the element */
|
||||
--avatarSize: 50cqmin; /* Half of the smallest dimension of the tile */
|
||||
width: var(--avatarSize) !important;
|
||||
height: var(--avatarSize) !important;
|
||||
border-radius: 10000px !important;
|
||||
.fg > button:hover {
|
||||
background: var(--cpd-color-bg-action-primary-hovered);
|
||||
}
|
||||
}
|
||||
|
||||
.fg > button:active {
|
||||
background: var(--cpd-color-bg-action-primary-pressed) !important;
|
||||
}
|
||||
|
||||
.fg > button[data-state="open"] {
|
||||
background: var(--cpd-color-bg-action-primary-pressed);
|
||||
}
|
||||
|
||||
.fg > button > svg {
|
||||
display: block;
|
||||
color: var(--cpd-color-icon-on-solid-primary);
|
||||
}
|
||||
|
||||
.fg > button:first-of-type {
|
||||
grid-area: button1;
|
||||
}
|
||||
|
||||
.fg > button:nth-of-type(2) {
|
||||
grid-area: button2;
|
||||
}
|
||||
|
||||
.volumeSlider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 - 2023 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.
|
||||
*/
|
||||
|
||||
.videoTileSettingsModal {
|
||||
width: 700px;
|
||||
height: 316px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
margin: 27px 34px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.localVolumePercentage {
|
||||
width: 3ch;
|
||||
}
|
||||
|
||||
.localVolumeSlider[type="range"] {
|
||||
-ms-appearance: none;
|
||||
-moz-appearance: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
background-color: transparent;
|
||||
--slider-color: var(--cpd-color-bg-subtle-primary);
|
||||
--slider-height: 4px;
|
||||
--thumb-color: var(--cpd-color-text-action-accent);
|
||||
--thumb-radius: 100%;
|
||||
--thumb-size: 16px;
|
||||
--thumb-margin-top: -6px;
|
||||
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.localVolumeSlider[type="range"]::-moz-range-track {
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
background-color: var(--slider-color);
|
||||
height: var(--slider-height);
|
||||
}
|
||||
.localVolumeSlider[type="range"]::-ms-track {
|
||||
-ms-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
background-color: var(--slider-color);
|
||||
height: var(--slider-height);
|
||||
}
|
||||
.localVolumeSlider[type="range"]::-webkit-slider-runnable-track {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
background-color: var(--slider-color);
|
||||
height: var(--slider-height);
|
||||
}
|
||||
|
||||
.localVolumeSlider[type="range"]::-moz-range-thumb {
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
height: var(--thumb-size);
|
||||
width: var(--thumb-size);
|
||||
margin-top: var(--thumb-margin-top);
|
||||
border-radius: var(--thumb-radius);
|
||||
background: var(--thumb-color);
|
||||
}
|
||||
.localVolumeSlider[type="range"]::-ms-thumb {
|
||||
-ms-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
height: var(--thumb-size);
|
||||
width: var(--thumb-size);
|
||||
margin-top: var(--thumb-margin-top);
|
||||
border-radius: var(--thumb-radius);
|
||||
background: var(--thumb-color);
|
||||
}
|
||||
.localVolumeSlider[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
height: var(--thumb-size);
|
||||
width: var(--thumb-size);
|
||||
margin-top: var(--thumb-margin-top);
|
||||
border-radius: var(--thumb-radius);
|
||||
background: var(--thumb-color);
|
||||
}
|
||||
|
||||
.localVolumeSlider[type="range"]::-moz-range-progress {
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
height: var(--slider-height);
|
||||
background: var(--thumb-color);
|
||||
}
|
||||
.localVolumeSlider[type="range"]::-ms-fill-lower {
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
height: var(--slider-height);
|
||||
background: var(--thumb-color);
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 - 2023 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 { ChangeEvent, FC, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemoteParticipant, Track } from "livekit-client";
|
||||
|
||||
import { FieldRow } from "../input/Input";
|
||||
import { Modal } from "../Modal";
|
||||
import styles from "./VideoTileSettingsModal.module.css";
|
||||
import { VolumeIcon } from "../button/VolumeIcon";
|
||||
|
||||
interface LocalVolumeProps {
|
||||
participant: RemoteParticipant;
|
||||
media: "user media" | "screen share";
|
||||
}
|
||||
|
||||
const LocalVolume: FC<LocalVolumeProps> = ({
|
||||
participant,
|
||||
media,
|
||||
}: LocalVolumeProps) => {
|
||||
const source =
|
||||
media === "user media"
|
||||
? Track.Source.Microphone
|
||||
: Track.Source.ScreenShareAudio;
|
||||
|
||||
const [localVolume, setLocalVolume] = useState<number>(
|
||||
participant.getVolume(source) ?? 0,
|
||||
);
|
||||
|
||||
const onLocalVolumeChanged = (event: ChangeEvent<HTMLInputElement>): void => {
|
||||
const value: number = +event.target.value;
|
||||
setLocalVolume(value);
|
||||
participant.setVolume(value, source);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldRow>
|
||||
<VolumeIcon volume={localVolume} />
|
||||
<input
|
||||
className={styles.localVolumeSlider}
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={localVolume}
|
||||
onChange={onLocalVolumeChanged}
|
||||
/>
|
||||
</FieldRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface Props {
|
||||
participant: RemoteParticipant;
|
||||
media: "user media" | "screen share";
|
||||
open: boolean;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export const VideoTileSettingsModal: FC<Props> = ({
|
||||
participant,
|
||||
media,
|
||||
open,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.videoTileSettingsModal}
|
||||
title={t("local_volume_label")}
|
||||
open={open}
|
||||
onDismiss={onDismiss}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<LocalVolume participant={participant} media={media} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user