mirror of
https://github.com/vector-im/element-call.git
synced 2026-10-16 23:45:50 +00:00
A transport may soon carry a LiveKit SFU `url` instead of, or as well as, a `livekit_service_url`, so a service URL alone no longer identifies it. Pass the whole transport to the tiles' debug stats and print it as JSON in log lines instead of picking out the service URL.
276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
/*
|
|
Copyright 2024 New Vector Ltd.
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
|
Please see LICENSE in the repository root for full details.
|
|
*/
|
|
|
|
import { type TrackReferenceOrPlaceholder } from "@livekit/components-core";
|
|
import { animated } from "@react-spring/web";
|
|
import {
|
|
type FC,
|
|
type ComponentProps,
|
|
type ReactNode,
|
|
type SyntheticEvent,
|
|
useState,
|
|
} from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import classNames from "classnames";
|
|
import { VideoTrack } from "@livekit/components-react";
|
|
import { Text, Tooltip } from "@vector-im/compound-web";
|
|
import { ErrorSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
|
|
|
import styles from "./MediaView.module.css";
|
|
import { Avatar } from "../Avatar";
|
|
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
|
|
import {
|
|
showConnectionStats as showConnectionStatsSetting,
|
|
showHandRaisedTimer,
|
|
useSetting,
|
|
} from "../settings/settings";
|
|
import { type ReactionOption } from "../reactions";
|
|
import { ReactionIndicator } from "../reactions/ReactionIndicator";
|
|
import { RTCConnectionStats } from "../RTCConnectionStats";
|
|
import videoPlaceholder from "../graphics/video-placeholder.gif";
|
|
import { autoVideoFit } from "../utils/videoFit";
|
|
import { type LivekitTransport } from "matrix-js-sdk/lib/matrixrtc";
|
|
|
|
interface Props extends ComponentProps<typeof animated.div> {
|
|
className?: string;
|
|
style?: ComponentProps<typeof animated.div>["style"];
|
|
targetWidth: number;
|
|
targetHeight: number;
|
|
video: TrackReferenceOrPlaceholder | undefined;
|
|
/**
|
|
* How to fit the video content inside the tile. When undefined, MediaView
|
|
* chooses a smart default based on the aspect ratios of the tile and video.
|
|
*/
|
|
videoFit?: "cover" | "contain";
|
|
mirror: boolean;
|
|
soundWaves?: boolean;
|
|
userId: string;
|
|
videoEnabled: boolean;
|
|
unencryptedWarning: boolean;
|
|
status?: ReactNode;
|
|
showNameTags: boolean;
|
|
nameTagLeadingIcon?: ReactNode;
|
|
displayName: string;
|
|
mxcAvatarUrl: string | undefined;
|
|
avatarStyle?: "solid" | "translucent";
|
|
background?: "solid" | "transparent";
|
|
focusable: boolean;
|
|
primaryButton?: ReactNode;
|
|
raisedHandTime?: Date;
|
|
currentReaction?: ReactionOption;
|
|
raisedHandOnClick?: () => void;
|
|
waitingForMedia?: boolean;
|
|
audioStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
|
videoStreamStats?: RTCInboundRtpStreamStats | RTCOutboundRtpStreamStats;
|
|
rtcBackendIdentity?: string;
|
|
/**
|
|
* The LiveKit transport, mainly for debugging purposes.
|
|
*/
|
|
transport?: LivekitTransport;
|
|
/**
|
|
* Called whenever the aspect ratio of the video content becomes known or
|
|
* otherwise changes.
|
|
*/
|
|
setVideoAspectRatio?: (ratio: number) => void;
|
|
}
|
|
|
|
export const MediaView: FC<Props> = ({
|
|
ref,
|
|
className,
|
|
style,
|
|
targetWidth,
|
|
targetHeight,
|
|
video,
|
|
videoFit,
|
|
mirror,
|
|
soundWaves,
|
|
userId,
|
|
videoEnabled,
|
|
unencryptedWarning,
|
|
showNameTags,
|
|
nameTagLeadingIcon,
|
|
displayName,
|
|
mxcAvatarUrl,
|
|
avatarStyle = "solid",
|
|
background = "solid",
|
|
focusable,
|
|
primaryButton,
|
|
status,
|
|
raisedHandTime,
|
|
currentReaction,
|
|
raisedHandOnClick,
|
|
waitingForMedia,
|
|
audioStreamStats,
|
|
videoStreamStats,
|
|
rtcBackendIdentity,
|
|
transport,
|
|
setVideoAspectRatio: setTheirVideoAspectRatio,
|
|
...props
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [handRaiseTimerVisible] = useSetting(showHandRaisedTimer);
|
|
const [showConnectionStats] = useSetting(showConnectionStatsSetting);
|
|
|
|
const avatarSize = Math.round(
|
|
Math.min(targetWidth, targetHeight) *
|
|
(soundWaves === undefined ? 0.5 : 0.38),
|
|
);
|
|
|
|
const [videoAspectRatio, setOurVideoAspectRatio] = useState<number>(NaN);
|
|
const tileAspectRatio = targetWidth / targetHeight;
|
|
|
|
// Propagate video dimensions
|
|
const setVideoAspectRatio = (ratio: number) => {
|
|
setOurVideoAspectRatio(ratio);
|
|
setTheirVideoAspectRatio?.(ratio);
|
|
};
|
|
const videoRef = (el: HTMLVideoElement | null) => {
|
|
if (el !== null) setVideoAspectRatio(el.videoWidth / el.videoHeight);
|
|
};
|
|
const onResize = (ev: SyntheticEvent<HTMLVideoElement>) =>
|
|
setVideoAspectRatio(
|
|
ev.currentTarget.videoWidth / ev.currentTarget.videoHeight,
|
|
);
|
|
|
|
const warnings = unencryptedWarning && (
|
|
<Tooltip
|
|
label={t("common.unencrypted")}
|
|
placement="bottom"
|
|
isTriggerInteractive={false}
|
|
nonInteractiveTriggerTabIndex={focusable ? undefined : -1}
|
|
>
|
|
<ErrorSolidIcon
|
|
width={20}
|
|
height={20}
|
|
className={styles.errorIcon}
|
|
role="img"
|
|
aria-label={t("common.unencrypted")}
|
|
/>
|
|
</Tooltip>
|
|
);
|
|
|
|
return (
|
|
<animated.div
|
|
className={classNames(styles.media, className, {
|
|
[styles.mirror]: mirror,
|
|
})}
|
|
style={style}
|
|
ref={ref}
|
|
data-testid="videoTile"
|
|
data-video-enabled={video && videoEnabled}
|
|
data-video-fit={
|
|
videoFit ?? autoVideoFit(videoAspectRatio, tileAspectRatio)
|
|
}
|
|
data-background={background}
|
|
{...props}
|
|
>
|
|
<div className={styles.bg}>
|
|
{soundWaves !== undefined && (
|
|
<div className={styles.waves} data-visible={soundWaves}>
|
|
<div className={styles.wave} />
|
|
<div className={styles.wave} />
|
|
<div className={styles.wave} />
|
|
<div className={styles.speakingBorder} />
|
|
</div>
|
|
)}
|
|
<Avatar
|
|
id={userId}
|
|
name={displayName}
|
|
size={avatarSize}
|
|
src={mxcAvatarUrl}
|
|
data-style={avatarStyle}
|
|
className={styles.avatar}
|
|
style={{ display: video && videoEnabled ? "none" : "initial" }}
|
|
/>
|
|
{video?.publication !== undefined && (
|
|
<VideoTrack
|
|
trackRef={video}
|
|
// There's no reason for this to be focusable
|
|
tabIndex={-1}
|
|
disablePictureInPicture
|
|
data-testid="video"
|
|
// Set the placeholder to a small transparent image. (On Android web
|
|
// views the default poster image is particularly ugly.)
|
|
poster={videoPlaceholder}
|
|
ref={videoRef}
|
|
onResize={onResize}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={styles.fg}>
|
|
<div className={styles.reactions}>
|
|
<RaisedHandIndicator
|
|
raisedHandTime={raisedHandTime}
|
|
miniature={avatarSize < 96}
|
|
showTimer={handRaiseTimerVisible}
|
|
onClick={raisedHandOnClick}
|
|
tabIndex={focusable ? undefined : -1}
|
|
/>
|
|
{currentReaction && (
|
|
<ReactionIndicator
|
|
miniature={avatarSize < 96}
|
|
emoji={currentReaction.emoji}
|
|
/>
|
|
)}
|
|
</div>
|
|
{waitingForMedia && (
|
|
<div className={styles.status}>
|
|
{t("video_tile.waiting_for_media")}
|
|
{showConnectionStats ? " " + rtcBackendIdentity : ""}
|
|
</div>
|
|
)}
|
|
{showConnectionStats && (
|
|
<>
|
|
<RTCConnectionStats
|
|
audio={audioStreamStats}
|
|
video={videoStreamStats}
|
|
transport={transport}
|
|
rtcBackendIdentity={rtcBackendIdentity}
|
|
/>
|
|
</>
|
|
)}
|
|
{status && <div className={styles.status}>{status}</div>}
|
|
{/* TODO: Bring this back once encryption status is less broken */}
|
|
{/*encryptionStatus !== EncryptionStatus.Okay && (
|
|
<div className={styles.status}>
|
|
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
|
{encryptionStatus === EncryptionStatus.Connecting &&
|
|
t("e2ee_encryption_status.connecting")}
|
|
{encryptionStatus === EncryptionStatus.KeyMissing &&
|
|
t("e2ee_encryption_status.key_missing")}
|
|
{encryptionStatus === EncryptionStatus.KeyInvalid &&
|
|
t("e2ee_encryption_status.key_invalid")}
|
|
{encryptionStatus === EncryptionStatus.PasswordInvalid &&
|
|
t("e2ee_encryption_status.password_invalid")}
|
|
</Text>
|
|
</div>
|
|
)*/}
|
|
{showNameTags && targetWidth >= 100 ? (
|
|
<div className={styles.nameTag}>
|
|
{nameTagLeadingIcon}
|
|
<Text
|
|
as="span"
|
|
size="sm"
|
|
weight="medium"
|
|
className={styles.name}
|
|
data-testid="name_tag"
|
|
>
|
|
{displayName}
|
|
</Text>
|
|
{warnings}
|
|
</div>
|
|
) : (
|
|
warnings
|
|
)}
|
|
{primaryButton}
|
|
</div>
|
|
</animated.div>
|
|
);
|
|
};
|
|
|
|
MediaView.displayName = "MediaView";
|