Merge branch 'main' into SimonBrandner/feat/settings

This commit is contained in:
Robin Townsend
2023-05-22 12:49:57 -04:00
52 changed files with 371 additions and 253 deletions

View File

@@ -134,7 +134,9 @@ export function RoomHeaderInfo({ roomName, avatarUrl }: RoomHeaderInfo) {
/>
<VideoIcon width={16} height={16} />
</div>
<Subtitle fontWeight="semiBold">{roomName}</Subtitle>
<Subtitle data-testid="roomHeader_roomName" fontWeight="semiBold">
{roomName}
</Subtitle>
</>
);
}

View File

@@ -92,6 +92,7 @@ export function Modal({
{...closeButtonProps}
ref={closeButtonRef}
className={styles.closeButton}
data-testid="modal_close"
title={t("Close")}
>
<CloseIcon />

View File

@@ -59,6 +59,7 @@ export function UserMenu({
key: "user",
icon: UserIcon,
label: displayName,
dataTestid: "usermenu_user",
});
arr.push({
key: "settings",
@@ -71,6 +72,7 @@ export function UserMenu({
key: "login",
label: t("Sign in"),
icon: LoginIcon,
dataTestid: "usermenu_login",
});
}
@@ -79,6 +81,7 @@ export function UserMenu({
key: "logout",
label: t("Sign out"),
icon: LogoutIcon,
dataTestid: "usermenu_logout",
});
}
}
@@ -99,7 +102,11 @@ export function UserMenu({
return (
<PopoverMenuTrigger placement="bottom right">
<TooltipTrigger tooltip={tooltip} placement="bottom left">
<Button variant="icon" className={styles.userButton}>
<Button
variant="icon"
className={styles.userButton}
data-testid="usermenu_open"
>
{isAuthenticated && (!isPasswordlessUser || avatarUrl) ? (
<Avatar
size={Size.SM}
@@ -114,9 +121,14 @@ export function UserMenu({
</TooltipTrigger>
{(props) => (
<Menu {...props} label={t("User menu")} onAction={onAction}>
{items.map(({ key, icon: Icon, label }) => (
{items.map(({ key, icon: Icon, label, dataTestid }) => (
<Item key={key} textValue={label}>
<Icon width={24} height={24} className={styles.menuIcon} />
<Icon
width={24}
height={24}
className={styles.menuIcon}
data-testid={dataTestid}
/>
<Body overflowEllipsis>{label}</Body>
</Item>
))}

View File

@@ -124,6 +124,8 @@ export class PosthogSpanProcessor implements SpanProcessor {
const audioReceived = `${attributes["matrix.stats.summary.percentageReceivedAudioMedia"]}`;
const maxJitter = `${attributes["matrix.stats.summary.maxJitter"]}`;
const maxPacketLoss = `${attributes["matrix.stats.summary.maxPacketLoss"]}`;
const peerConnections = `${attributes["matrix.stats.summary.peerConnections"]}`;
const percentageConcealedAudio = `${attributes["matrix.stats.summary.percentageConcealedAudio"]}`;
PosthogAnalytics.instance.trackEvent(
{
eventName: "MediaReceived",
@@ -133,6 +135,8 @@ export class PosthogSpanProcessor implements SpanProcessor {
videoReceived: videoReceived,
maxJitter: maxJitter,
maxPacketLoss: maxPacketLoss,
peerConnections: peerConnections,
percentageConcealedAudio: percentageConcealedAudio,
},
// Send instantly because the window might be closing
{ send_instantly: true }

View File

@@ -88,6 +88,7 @@ export const LoginPage: FC = () => {
autoCapitalize="none"
prefix="@"
suffix={`:${Config.defaultServerName()}`}
data-testid="login_username"
/>
</FieldRow>
<FieldRow>
@@ -96,6 +97,7 @@ export const LoginPage: FC = () => {
ref={passwordRef}
placeholder={t("Password")}
label={t("Password")}
data-testid="login_password"
/>
</FieldRow>
{error && (
@@ -104,7 +106,11 @@ export const LoginPage: FC = () => {
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={loading}>
<Button
type="submit"
disabled={loading}
data-testid="login_login"
>
{loading ? t("Logging in…") : t("Login")}
</Button>
</FieldRow>

View File

@@ -166,6 +166,7 @@ export const RegisterPage: FC = () => {
autoCapitalize="none"
prefix="@"
suffix={`:${Config.defaultServerName()}`}
data-testid="register_username"
/>
</FieldRow>
<FieldRow>
@@ -179,6 +180,7 @@ export const RegisterPage: FC = () => {
value={password}
placeholder={t("Password")}
label={t("Password")}
data-testid="register_password"
/>
</FieldRow>
<FieldRow>
@@ -193,6 +195,7 @@ export const RegisterPage: FC = () => {
placeholder={t("Confirm password")}
label={t("Confirm password")}
ref={confirmPasswordRef}
data-testid="register_confirm_password"
/>
</FieldRow>
<Caption>
@@ -217,7 +220,11 @@ export const RegisterPage: FC = () => {
</FieldRow>
)}
<FieldRow>
<Button type="submit" disabled={registering}>
<Button
type="submit"
disabled={registering}
data-testid="register_register"
>
{registering ? t("Registering…") : t("Register")}
</Button>
</FieldRow>

View File

@@ -43,7 +43,9 @@ export function JoinExistingCallModal({ onJoin, onClose, ...rest }: Props) {
<p>{t("This call already exists, would you like to join?")}</p>
<FieldRow rightAlign className={styles.buttons}>
<Button onPress={onClose}>{t("No")}</Button>
<Button onPress={onJoin}>{t("Yes, join call")}</Button>
<Button onPress={onJoin} data-testid="home_joinExistingRoom">
{t("Yes, join call")}
</Button>
</FieldRow>
</ModalContent>
</Modal>

View File

@@ -133,6 +133,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
type="text"
required
autoComplete="off"
data-testid="home_callName"
/>
<Button
@@ -140,6 +141,7 @@ export function RegisteredView({ client, isPasswordlessUser }: Props) {
size="lg"
className={styles.button}
disabled={loading}
data-testid="home_go"
>
{loading ? t("Loading…") : t("Go")}
</Button>

View File

@@ -142,6 +142,7 @@ export const UnauthenticatedView: FC = () => {
type="text"
required
autoComplete="off"
data-testid="home_callName"
/>
</FieldRow>
<FieldRow>
@@ -152,6 +153,7 @@ export const UnauthenticatedView: FC = () => {
placeholder={t("Display name")}
type="text"
required
data-testid="home_displayName"
autoComplete="off"
/>
</FieldRow>
@@ -171,7 +173,12 @@ export const UnauthenticatedView: FC = () => {
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
<Button
type="submit"
size="lg"
disabled={loading}
data-testid="home_go"
>
{loading ? t("Loading…") : t("Go")}
</Button>
<div id={recaptchaId} />
@@ -179,14 +186,14 @@ export const UnauthenticatedView: FC = () => {
</main>
<footer className={styles.footer}>
<Body className={styles.mobileLoginLink}>
<Link color="primary" to="/login">
<Link color="primary" to="/login" data-testid="home_login">
{t("Login to your account")}
</Link>
</Body>
<Body>
<Trans>
Not registered yet?{" "}
<Link color="primary" to="/register">
<Link color="primary" to="/register" data-testid="home_register">
Create an account
</Link>
</Trans>

View File

@@ -1,4 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg data-testid="videoTile_muted" width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0.20333 0.963373C0.474437 0.690007 0.913989 0.690007 1.1851 0.963373L11.5983 11.4633C11.8694 11.7367 11.8694 12.1799 11.5983 12.4533C11.3272 12.7267 10.8876 12.7267 10.6165 12.4533L0.20333 1.95332C-0.0677768 1.67995 -0.0677768 1.23674 0.20333 0.963373Z" fill="white"/>
<path d="M0.418261 3.63429C0.226267 3.95219 0.115674 4.32557 0.115674 4.725V9.85832C0.115674 11.0181 1.0481 11.9583 2.19831 11.9583H8.65411L0.447396 3.66596C0.437225 3.65568 0.427513 3.64511 0.418261 3.63429Z" fill="white"/>
<path d="M9.95036 4.725V8.33212L4.30219 2.625H7.86772C9.01793 2.625 9.95036 3.5652 9.95036 4.725Z" fill="white"/>

Before

Width:  |  Height:  |  Size: 892 B

After

Width:  |  Height:  |  Size: 922 B

View File

@@ -47,7 +47,7 @@ export async function findDeviceByName(
*
* @return The available media devices
*/
export async function getDevices(): Promise<MediaDeviceInfo[]> {
export async function getNamedDevices(): Promise<MediaDeviceInfo[]> {
// First get the devices without their labels, to learn what kinds of streams
// we can request
let devices: MediaDeviceInfo[];

View File

@@ -42,7 +42,7 @@ export class ElementCallOpenTelemetry {
const config = Config.get();
// we always enable opentelemetry in general. We only enable the OTLP
// collector if a URL is defined (and in future if another setting is defined)
// The posthog exporter is always enabled, posthog reporting is enabled or disabled
// Posthog reporting is enabled or disabled
// within the posthog code.
const shouldEnableOtlp = Boolean(config.opentelemetry?.collector_url);

View File

@@ -51,7 +51,7 @@ export function GroupCallLoader({
if (loading) {
return (
<FullScreenView>
<h1>{t("Loading room…")}</h1>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
}

View File

@@ -34,7 +34,7 @@ import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
import { findDeviceByName, getNamedDevices } from "../media-utils";
declare global {
interface Window {
@@ -102,7 +102,7 @@ export function GroupCallView({
// Get the available devices so we can match the selected device
// to its ID. This involves getting a media stream (see docs on
// the function) so we only do it once and re-use the result.
const devices = await getDevices();
const devices = await getNamedDevices();
const { audioInput, videoInput } = ev.detail
.data as unknown as JoinCallData;
@@ -281,7 +281,7 @@ export function GroupCallView({
} else if (isEmbedded) {
return (
<FullScreenView>
<h1>{t("Loading room…")}</h1>
<h1>{t("Loading…")}</h1>
</FullScreenView>
);
} else {

View File

@@ -407,11 +407,13 @@ export function InCallView({
key="1"
muted={microphoneMuted}
onPress={toggleMicrophoneMuted}
data-testid="incall_mute"
/>,
<VideoButton
key="2"
muted={localVideoMuted}
onPress={toggleLocalVideoMuted}
data-testid="incall_videomute"
/>
);
@@ -422,6 +424,7 @@ export function InCallView({
key="3"
enabled={isScreensharing}
onPress={toggleScreensharing}
data-testid="incall_screenshare"
/>
);
}
@@ -430,7 +433,9 @@ export function InCallView({
}
}
buttons.push(<HangupButton key="6" onPress={onLeave} />);
buttons.push(
<HangupButton key="6" onPress={onLeave} data-testid="incall_leave" />
);
footer = <div className={styles.footer}>{buttons}</div>;
}

View File

@@ -41,6 +41,7 @@ export const InviteModal: FC<Props> = ({ roomIdOrAlias, ...rest }) => {
<CopyButton
className={styles.copyButton}
value={getRoomUrl(roomIdOrAlias)}
data-testid="modal_inviteLink"
/>
</ModalContent>
</Modal>

View File

@@ -137,6 +137,7 @@ export function LobbyView({
size="lg"
disabled={state !== GroupCallState.LocalCallFeedInitialized}
onPress={onEnter}
data-testid="lobby_joinCall"
>
Join call now
</Button>
@@ -146,6 +147,7 @@ export function LobbyView({
value={getRoomUrl(roomIdOrAlias)}
className={styles.copyButton}
copiedMessage={t("Call link copied")}
data-testid="lobby_inviteLink"
>
Copy call link and join later
</CopyButton>

View File

@@ -74,6 +74,7 @@ export function RoomAuthView() {
name="displayName"
label={t("Display name")}
placeholder={t("Display name")}
data-testid="joincall_displayName"
type="text"
required
autoComplete="off"
@@ -90,7 +91,12 @@ export function RoomAuthView() {
<ErrorMessage error={error} />
</FieldRow>
)}
<Button type="submit" size="lg" disabled={loading}>
<Button
type="submit"
size="lg"
disabled={loading}
data-testid="joincall_joincall"
>
{loading ? t("Loading…") : t("Join call now")}
</Button>
<div id={recaptchaId} />

View File

@@ -77,7 +77,13 @@ export function VideoPreview({
return (
<div className={styles.preview} ref={previewRef}>
<video ref={videoRef} muted playsInline disablePictureInPicture />
<video
ref={videoRef}
muted
playsInline
disablePictureInPicture
data-testid="preview_video"
/>
{state === GroupCallState.LocalCallFeedUninitialized && (
<Body fontWeight="semiBold" className={styles.cameraPermissions}>
{t("Camera/microphone permissions needed to join the call.")}

View File

@@ -32,7 +32,7 @@ import { isLocalRoomId, createRoom, roomNameFromRoomId } from "../matrix-utils";
import { translatedError } from "../TranslatedError";
import { widget } from "../widget";
const STATS_COLLECT_INTERVAL_TIME_MS = 30000;
const STATS_COLLECT_INTERVAL_TIME_MS = 10000;
export interface GroupCallLoadState {
loading: boolean;

View File

@@ -106,6 +106,7 @@ export function ProfileSettingsTab({ client }: Props) {
placeholder={t("Display name")}
value={displayName}
onChange={onChangeDisplayName}
data-testid="profile_displayname"
/>
</FieldRow>
{error && (

View File

@@ -65,7 +65,9 @@ export const SettingsModal = (props: Props) => {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
} = useMediaHandler();
useDeviceNames();
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();

View File

@@ -249,7 +249,7 @@ export function useSubmitRageshake(): {
body.append(
"file",
gzip(ElementCallOpenTelemetry.instance.rageshakeProcessor!.dump()),
"traces.json"
"traces.json.gz"
);
if (inspectorState) {

View File

@@ -14,9 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { MediaHandlerEvent } from "matrix-js-sdk/src/webrtc/mediaHandler";
import { MatrixClient } from "matrix-js-sdk/src/client";
import React, {
useState,
useEffect,
@@ -25,20 +23,27 @@ import React, {
useContext,
createContext,
ReactNode,
useRef,
} from "react";
import { useClient } from "../ClientContext";
import { getNamedDevices } from "../media-utils";
export interface MediaHandlerContextInterface {
audioInput: string;
audioInput: string | undefined;
audioInputs: MediaDeviceInfo[];
setAudioInput: (deviceId: string) => void;
videoInput: string;
videoInput: string | undefined;
videoInputs: MediaDeviceInfo[];
setVideoInput: (deviceId: string) => void;
audioOutput: string;
audioOutput: string | undefined;
audioOutputs: MediaDeviceInfo[];
setAudioOutput: (deviceId: string) => void;
/**
* A hook which requests for devices to be named. This requires media
* permissions.
*/
useDeviceNames: () => void;
}
const MediaHandlerContext =
@@ -49,6 +54,7 @@ interface MediaPreferences {
videoInput?: string;
audioOutput?: string;
}
function getMediaPreferences(): MediaPreferences {
const mediaPreferences = localStorage.getItem("matrix-media-preferences");
@@ -56,10 +62,10 @@ function getMediaPreferences(): MediaPreferences {
try {
return JSON.parse(mediaPreferences);
} catch (e) {
return undefined;
return {};
}
} else {
return undefined;
return {};
}
}
@@ -74,9 +80,11 @@ function updateMediaPreferences(newPreferences: MediaPreferences): void {
})
);
}
interface Props {
children: ReactNode;
}
export function MediaHandlerProvider({ children }: Props): JSX.Element {
const { client } = useClient();
const [
@@ -89,122 +97,109 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutputs,
},
setState,
] = useState(() => {
const mediaHandler = client?.getMediaHandler();
] = useState(() => ({
audioInput: undefined as string | undefined,
videoInput: undefined as string | undefined,
audioOutput: undefined as string | undefined,
audioInputs: [] as MediaDeviceInfo[],
videoInputs: [] as MediaDeviceInfo[],
audioOutputs: [] as MediaDeviceInfo[],
}));
if (mediaHandler) {
// A ref counting the number of components currently mounted that want
// to know device names
const numComponentsWantingNames = useRef(0);
const updateDevices = useCallback(
async (client: MatrixClient, initial: boolean) => {
// Only request device names if components actually want them, because it
// could trigger an extra permission pop-up
const devices = await (numComponentsWantingNames.current > 0
? getNamedDevices()
: navigator.mediaDevices.enumerateDevices());
const mediaPreferences = getMediaPreferences();
mediaHandler?.restoreMediaSettings(
mediaPreferences?.audioInput,
mediaPreferences?.videoInput
);
}
return {
// @ts-ignore, ignore that audioInput is a private members of mediaHandler
audioInput: mediaHandler?.audioInput,
// @ts-ignore, ignore that videoInput is a private members of mediaHandler
videoInput: mediaHandler?.videoInput,
audioOutput: undefined,
audioInputs: [],
videoInputs: [],
audioOutputs: [],
};
});
const audioInputs = devices.filter((d) => d.kind === "audioinput");
const videoInputs = devices.filter((d) => d.kind === "videoinput");
const audioOutputs = devices.filter((d) => d.kind === "audiooutput");
const audioInput = (
mediaPreferences.audioInput === undefined
? audioInputs.at(0)
: audioInputs.find(
(d) => d.deviceId === mediaPreferences.audioInput
) ?? audioInputs.at(0)
)?.deviceId;
const videoInput = (
mediaPreferences.videoInput === undefined
? videoInputs.at(0)
: videoInputs.find(
(d) => d.deviceId === mediaPreferences.videoInput
) ?? videoInputs.at(0)
)?.deviceId;
const audioOutput =
mediaPreferences.audioOutput === undefined
? undefined
: audioOutputs.find(
(d) => d.deviceId === mediaPreferences.audioOutput
)?.deviceId;
updateMediaPreferences({ audioInput, videoInput, audioOutput });
setState({
audioInput,
videoInput,
audioOutput,
audioInputs,
videoInputs,
audioOutputs,
});
if (
initial ||
audioInput !== mediaPreferences.audioInput ||
videoInput !== mediaPreferences.videoInput
) {
client.getMediaHandler().setMediaInputs(audioInput, videoInput);
}
},
[setState]
);
const useDeviceNames = useCallback(() => {
// This is a little weird from React's perspective as it looks like a
// dynamic hook, but it works
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (client) {
numComponentsWantingNames.current++;
if (numComponentsWantingNames.current === 1)
updateDevices(client, false);
return () => void numComponentsWantingNames.current--;
}
}, []);
}, [client, updateDevices]);
useEffect(() => {
if (!client) return;
if (client) {
updateDevices(client, true);
const onDeviceChange = () => updateDevices(client, false);
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
const mediaHandler = client.getMediaHandler();
function updateDevices(): void {
navigator.mediaDevices.enumerateDevices().then((devices) => {
const mediaPreferences = getMediaPreferences();
const audioInputs = devices.filter(
(device) => device.kind === "audioinput"
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
onDeviceChange
);
const audioConnected = audioInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.audioInput
);
// @ts-ignore
let audioInput = mediaHandler.audioInput;
if (!audioConnected && audioInputs.length > 0) {
audioInput = audioInputs[0].deviceId;
}
const videoInputs = devices.filter(
(device) => device.kind === "videoinput"
);
const videoConnected = videoInputs.some(
// @ts-ignore
(device) => device.deviceId === mediaHandler.videoInput
);
// @ts-ignore
let videoInput = mediaHandler.videoInput;
if (!videoConnected && videoInputs.length > 0) {
videoInput = videoInputs[0].deviceId;
}
const audioOutputs = devices.filter(
(device) => device.kind === "audiooutput"
);
let audioOutput = undefined;
if (
mediaPreferences &&
audioOutputs.some(
(device) => device.deviceId === mediaPreferences.audioOutput
)
) {
audioOutput = mediaPreferences.audioOutput;
}
if (
// @ts-ignore
(mediaHandler.videoInput && mediaHandler.videoInput !== videoInput) ||
// @ts-ignore
mediaHandler.audioInput !== audioInput
) {
mediaHandler.setMediaInputs(audioInput, videoInput);
}
updateMediaPreferences({ audioInput, videoInput, audioOutput });
setState({
audioInput,
videoInput,
audioOutput,
audioInputs,
videoInputs,
audioOutputs,
});
});
client.getMediaHandler().stopAllStreams();
};
}
updateDevices();
mediaHandler.on(MediaHandlerEvent.LocalStreamsChanged, updateDevices);
navigator.mediaDevices.addEventListener("devicechange", updateDevices);
return () => {
mediaHandler.removeListener(
MediaHandlerEvent.LocalStreamsChanged,
updateDevices
);
navigator.mediaDevices.removeEventListener("devicechange", updateDevices);
mediaHandler.stopAllStreams();
};
}, [client]);
}, [client, updateDevices]);
const setAudioInput: (deviceId: string) => void = useCallback(
(deviceId: string) => {
updateMediaPreferences({ audioInput: deviceId });
setState((prevState) => ({ ...prevState, audioInput: deviceId }));
client.getMediaHandler().setAudioInput(deviceId);
client?.getMediaHandler().setAudioInput(deviceId);
},
[client]
);
@@ -235,6 +230,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
}),
[
audioInput,
@@ -246,6 +242,7 @@ export function MediaHandlerProvider({ children }: Props): JSX.Element {
audioOutput,
audioOutputs,
setAudioOutput,
useDeviceNames,
]
);

View File

@@ -245,6 +245,7 @@ export const NewVideoGrid: FC<Props> = ({
opacity: 0,
scale: 0,
shadow: 1,
shadowSpread: 0,
zIndex: 1,
x,
y,

View File

@@ -51,6 +51,7 @@ export interface TileSpring {
opacity: number;
scale: number;
shadow: number;
shadowSpread: number;
zIndex: number;
x: number;
y: number;
@@ -172,8 +173,16 @@ function getOneOnOneLayoutTilePositions(
const gridAspectRatio = gridWidth / gridHeight;
const smallPip = gridAspectRatio < 1 || gridWidth < 700;
const pipWidth = smallPip ? 114 : 230;
const pipHeight = smallPip ? 163 : 155;
const maxPipWidth = smallPip ? 114 : 230;
const maxPipHeight = smallPip ? 163 : 155;
// Cap the PiP size at 1/3 the remote tile size, preserving aspect ratio
const pipScaleFactor = Math.min(
1,
remotePosition.width / 3 / maxPipWidth,
remotePosition.height / 3 / maxPipHeight
);
const pipWidth = maxPipWidth * pipScaleFactor;
const pipHeight = maxPipHeight * pipScaleFactor;
const pipGap = getPipGap(gridAspectRatio, gridWidth);
const pipMinX = remotePosition.x + pipGap;
@@ -892,6 +901,8 @@ export function VideoGrid({
// Whether the tile positions were valid at the time of the previous
// animation
const tilePositionsWereValid = tilePositionsValid.current;
const oneOnOneLayout =
tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused);
return (tileIndex: number) => {
const tile = tiles[tileIndex];
@@ -911,12 +922,14 @@ export function VideoGrid({
opacity: 1,
zIndex: 2,
shadow: 15,
shadowSpread: 0,
immediate: (key: string) =>
disableAnimations ||
key === "zIndex" ||
key === "x" ||
key === "y" ||
key === "shadow",
key === "shadow" ||
key === "shadowSpread",
from: {
shadow: 0,
scale: 0,
@@ -974,10 +987,14 @@ export function VideoGrid({
opacity: remove ? 0 : 1,
zIndex: tilePosition.zIndex,
shadow: 1,
shadowSpread: oneOnOneLayout && tile.item.isLocal ? 1 : 0,
from,
reset,
immediate: (key: string) =>
disableAnimations || key === "zIndex" || key === "shadow",
disableAnimations ||
key === "zIndex" ||
key === "shadow" ||
key === "shadowSpread",
// If we just stopped dragging a tile, give it time for the
// animation to settle before pushing its z-index back down
delay: (key: string) => (key === "zIndex" ? 500 : 0),

View File

@@ -22,6 +22,8 @@ limitations under the License.
height: var(--tileHeight);
--tileRadius: 8px;
border-radius: var(--tileRadius);
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
overflow: hidden;
cursor: pointer;
@@ -45,7 +47,7 @@ limitations under the License.
transform: scaleX(-1);
}
.videoTile.speaking::after {
.videoTile::after {
position: absolute;
top: -1px;
left: -1px;
@@ -54,6 +56,12 @@ limitations under the License.
content: "";
border-radius: var(--tileRadius);
box-shadow: inset 0 0 0 4px var(--accent) !important;
opacity: 0;
transition: opacity ease 0.15s;
}
.videoTile.speaking::after {
opacity: 1;
}
.videoTile.maximised {
@@ -83,6 +91,12 @@ limitations under the License.
z-index: 1;
}
.infoBubble > svg {
height: 16px;
width: 16px;
margin-right: 4px;
}
.toolbar {
position: absolute;
top: 0;
@@ -126,10 +140,6 @@ limitations under the License.
bottom: 16px;
}
.memberName > * {
margin-right: 6px;
}
.memberName > :last-child {
margin-right: 0px;
}

View File

@@ -20,8 +20,8 @@ import classNames from "classnames";
import { useTranslation } from "react-i18next";
import styles from "./VideoTile.module.css";
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button";
import { ConnectionState } from "../room/useGroupCall";
@@ -47,6 +47,7 @@ interface Props {
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
shadowSpread?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;
@@ -79,6 +80,7 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
opacity,
scale,
shadow,
shadowSpread,
zIndex,
x,
y,
@@ -141,9 +143,6 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
style={{
opacity,
scale,
boxShadow: shadow?.to(
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
),
zIndex,
x,
y,
@@ -152,8 +151,11 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
// but React's types say no
"--tileWidth": width?.to((w) => `${w}px`),
"--tileHeight": height?.to((h) => `${h}px`),
"--tileShadow": shadow?.to((s) => `${s}px`),
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
}}
ref={ref as ForwardedRef<HTMLDivElement>}
data-testid="videoTile"
{...rest}
>
{toolbarButtons.length > 0 && !maximised && (
@@ -177,13 +179,19 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
Mute state is currently sent over to-device messages, which
aren't quite real-time, so this is an important kludge to make
sure no one appears muted when they've clearly begun talking. */
audioMuted && !videoMuted && !speaking && <MicMutedIcon />
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
}
{videoMuted && <VideoMutedIcon />}
<span title={caption}>{caption}</span>
<span data-testid="videoTile_caption" title={caption}>
{caption}
</span>
</div>
))}
<video ref={mediaRef} playsInline disablePictureInPicture />
<video
data-testid="videoTile_video"
ref={mediaRef}
playsInline
disablePictureInPicture
/>
</animated.div>
);
}

View File

@@ -47,6 +47,7 @@ interface Props {
opacity?: SpringValue<number>;
scale?: SpringValue<number>;
shadow?: SpringValue<number>;
shadowSpread?: SpringValue<number>;
zIndex?: SpringValue<number>;
x?: SpringValue<number>;
y?: SpringValue<number>;