mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-27 00:48:06 +08:00
Merge pull request #1104 from robintown/decouple-grid
Decouple video grid from video tile components
This commit is contained in:
commit
bde13e0fab
@ -82,17 +82,21 @@ limitations under the License.
|
||||
bottom: 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: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
|
||||
width: var(--avatarSize) !important;
|
||||
height: var(--avatarSize) !important;
|
||||
border-radius: 10000px !important;
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 300px) {
|
||||
|
@ -44,12 +44,7 @@ import {
|
||||
RoomHeaderInfo,
|
||||
VersionMismatchWarning,
|
||||
} from "../Header";
|
||||
import {
|
||||
VideoGrid,
|
||||
useVideoGridLayout,
|
||||
ChildrenProperties,
|
||||
} from "../video-grid/VideoGrid";
|
||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
||||
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
||||
import { GroupCallInspector } from "./GroupCallInspector";
|
||||
import { GridLayoutMenu } from "./GridLayoutMenu";
|
||||
import { Avatar } from "../Avatar";
|
||||
@ -77,6 +72,7 @@ import { SettingsModal } from "../settings/SettingsModal";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { VideoTile } from "../video-grid/VideoTile";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
@ -303,7 +299,7 @@ export function InCallView({
|
||||
}
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTileContainer
|
||||
<VideoTile
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
@ -311,10 +307,10 @@ export function InCallView({
|
||||
getAvatar={renderAvatar}
|
||||
audioContext={audioContext}
|
||||
audioDestination={audioDestination}
|
||||
disableSpeakingIndicator={true}
|
||||
maximised={Boolean(maximisedParticipant)}
|
||||
fullscreen={maximisedParticipant === fullscreenParticipant}
|
||||
onFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -325,17 +321,16 @@ export function InCallView({
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
>
|
||||
{({ item, ...rest }: ChildrenProperties) => (
|
||||
<VideoTileContainer
|
||||
item={item}
|
||||
{(props) => (
|
||||
<VideoTile
|
||||
getAvatar={renderAvatar}
|
||||
audioContext={audioContext}
|
||||
audioDestination={audioDestination}
|
||||
disableSpeakingIndicator={items.length < 3}
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onFullscreen={toggleFullscreen}
|
||||
{...rest}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
@ -21,14 +21,14 @@ import { MutableRefObject, RefCallback, useCallback } from "react";
|
||||
* same DOM node.
|
||||
*/
|
||||
export const useMergedRefs = <T>(
|
||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
|
||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
|
||||
): RefCallback<T | null> =>
|
||||
useCallback(
|
||||
(value) =>
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else {
|
||||
} else if (ref !== null) {
|
||||
ref.current = value;
|
||||
}
|
||||
}),
|
||||
|
@ -45,6 +45,7 @@ import {
|
||||
cycleTileSize,
|
||||
appendItems,
|
||||
} from "./model";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
|
||||
interface GridState extends Grid {
|
||||
/**
|
||||
@ -452,16 +453,19 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
>
|
||||
{slots}
|
||||
</div>
|
||||
{tileTransitions((style, tile) =>
|
||||
children({
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tile.width,
|
||||
targetHeight: tile.height,
|
||||
item: tile.item,
|
||||
onDragRef: onTileDragRef,
|
||||
})
|
||||
)}
|
||||
{tileTransitions((spring, tile) => (
|
||||
<TileWrapper
|
||||
key={tile.item.id}
|
||||
id={tile.item.id}
|
||||
onDragRef={onTileDragRef}
|
||||
targetWidth={tile.width}
|
||||
targetHeight={tile.height}
|
||||
item={tile.item}
|
||||
{...spring}
|
||||
>
|
||||
{children}
|
||||
</TileWrapper>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
101
src/video-grid/TileWrapper.tsx
Normal file
101
src/video-grid/TileWrapper.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 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 React, { FC, memo, ReactNode, RefObject, useRef } from "react";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
import { SpringValue, to } from "@react-spring/web";
|
||||
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { ChildrenProperties } from "./VideoGrid";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
onDragRef: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => void
|
||||
>;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
item: TileDescriptor;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
shadowSpread: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
children: (props: ChildrenProperties) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around a tile in a video grid. This component exists to decouple
|
||||
* child components from the grid.
|
||||
*/
|
||||
export const TileWrapper: FC<Props> = memo(
|
||||
({
|
||||
id,
|
||||
onDragRef,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
item,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
|
||||
useDrag((state) => onDragRef?.current!(id, state), {
|
||||
target: ref,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
ref,
|
||||
style: {
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
boxShadow: to(
|
||||
[shadow, shadowSpread],
|
||||
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`
|
||||
),
|
||||
},
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
item,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
@ -1,97 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 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 React, { useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk";
|
||||
|
||||
import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { Button } from "../button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
export default {
|
||||
title: "VideoGrid",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export const ParticipantsTest = () => {
|
||||
const { layout, setLayout } = useVideoGridLayout(false);
|
||||
const [participantCount, setParticipantCount] = useState(1);
|
||||
|
||||
const items: TileDescriptor[] = useMemo(
|
||||
() =>
|
||||
new Array(participantCount).fill(undefined).map((_, i) => ({
|
||||
id: (i + 1).toString(),
|
||||
member: new RoomMember("!fake:room.id", `@user${i}:fake.dummy`),
|
||||
focused: false,
|
||||
presenter: false,
|
||||
connectionState: ConnectionState.Connected,
|
||||
})),
|
||||
[participantCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", width: "100vw", height: "32px" }}>
|
||||
<Button
|
||||
onPress={() =>
|
||||
setLayout(layout === "freedom" ? "spotlight" : "freedom")
|
||||
}
|
||||
>
|
||||
Toggle Layout
|
||||
</Button>
|
||||
{participantCount < 12 && (
|
||||
<Button onPress={() => setParticipantCount((count) => count + 1)}>
|
||||
Add Participant
|
||||
</Button>
|
||||
)}
|
||||
{participantCount > 0 && (
|
||||
<Button onPress={() => setParticipantCount((count) => count - 1)}>
|
||||
Remove Participant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100vw",
|
||||
height: "calc(100vh - 32px)",
|
||||
}}
|
||||
>
|
||||
<VideoGrid layout={layout} items={items}>
|
||||
{({ item, ...rest }) => (
|
||||
<VideoTile
|
||||
key={item.id}
|
||||
name={`User ${item.id}`}
|
||||
disableSpeakingIndicator={items.length < 3}
|
||||
connectionState={ConnectionState.Connected}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</VideoGrid>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ParticipantsTest.args = {
|
||||
layout: "freedom",
|
||||
participantCount: 1,
|
||||
};
|
@ -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.
|
||||
@ -14,21 +14,34 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
Key,
|
||||
Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
EventTypes,
|
||||
FullGestureState,
|
||||
Handler,
|
||||
useGesture,
|
||||
} from "@use-gesture/react";
|
||||
import {
|
||||
animated,
|
||||
SpringRef,
|
||||
SpringValue,
|
||||
SpringValues,
|
||||
useSprings,
|
||||
} from "@react-spring/web";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
|
||||
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
|
||||
|
||||
import styles from "./VideoGrid.module.css";
|
||||
import { Layout } from "../room/GridLayoutMenu";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
|
||||
interface TilePosition {
|
||||
x: number;
|
||||
@ -39,7 +52,7 @@ interface TilePosition {
|
||||
}
|
||||
|
||||
interface Tile {
|
||||
key: Key;
|
||||
key: string;
|
||||
order: number;
|
||||
item: TileDescriptor;
|
||||
remove: boolean;
|
||||
@ -717,20 +730,18 @@ interface DragTileData {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ChildrenProperties extends ReactDOMAttributes {
|
||||
key: Key;
|
||||
export interface ChildrenProperties {
|
||||
ref: Ref<HTMLElement>;
|
||||
style: ComponentProps<typeof animated.div>["style"];
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The height this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
item: TileDescriptor;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
[index: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VideoGridProps {
|
||||
@ -1063,117 +1074,132 @@ export function VideoGrid({
|
||||
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
|
||||
);
|
||||
|
||||
const bindTile = useDrag(
|
||||
({ args: [key], active, xy, movement, tap, last, event }) => {
|
||||
event.preventDefault();
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||
// ends up breaking memoization and ruining this component's performance.
|
||||
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
||||
// gesture using the much more sensible ref-based method.
|
||||
const onTileDrag = (
|
||||
tileId: string,
|
||||
{
|
||||
active,
|
||||
xy,
|
||||
movement,
|
||||
tap,
|
||||
last,
|
||||
event,
|
||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (tap) {
|
||||
onTap(key);
|
||||
return;
|
||||
}
|
||||
if (tap) {
|
||||
onTap(tileId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout !== "freedom") return;
|
||||
if (layout !== "freedom") return;
|
||||
|
||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
|
||||
const dragTile = tiles[dragTileIndex];
|
||||
const dragTilePosition = tilePositions[dragTile.order];
|
||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === tileId);
|
||||
const dragTile = tiles[dragTileIndex];
|
||||
const dragTilePosition = tilePositions[dragTile.order];
|
||||
|
||||
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
|
||||
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
|
||||
|
||||
let newTiles = tiles;
|
||||
let newTiles = tiles;
|
||||
|
||||
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
|
||||
// We're in 1:1 mode, so only the local tile should be draggable
|
||||
if (!dragTile.item.isLocal) return;
|
||||
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
|
||||
// We're in 1:1 mode, so only the local tile should be draggable
|
||||
if (!dragTile.item.isLocal) return;
|
||||
|
||||
// Position should only update on the very last event, to avoid
|
||||
// compounding the offset on every drag event
|
||||
if (last) {
|
||||
const remotePosition = tilePositions[1];
|
||||
// Position should only update on the very last event, to avoid
|
||||
// compounding the offset on every drag event
|
||||
if (last) {
|
||||
const remotePosition = tilePositions[1];
|
||||
|
||||
const pipGap = getPipGap(
|
||||
gridBounds.width / gridBounds.height,
|
||||
gridBounds.width
|
||||
);
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
const pipMinY = remotePosition.y + pipGap;
|
||||
const pipMaxX =
|
||||
remotePosition.x +
|
||||
remotePosition.width -
|
||||
dragTilePosition.width -
|
||||
pipGap;
|
||||
const pipMaxY =
|
||||
remotePosition.y +
|
||||
remotePosition.height -
|
||||
dragTilePosition.height -
|
||||
pipGap;
|
||||
|
||||
const newPipXRatio =
|
||||
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
|
||||
const newPipYRatio =
|
||||
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
|
||||
|
||||
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
|
||||
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
|
||||
}
|
||||
} else {
|
||||
const hoverTile = tiles.find(
|
||||
(tile) =>
|
||||
tile.key !== key &&
|
||||
isInside(cursorPosition, tilePositions[tile.order])
|
||||
const pipGap = getPipGap(
|
||||
gridBounds.width / gridBounds.height,
|
||||
gridBounds.width
|
||||
);
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
const pipMinY = remotePosition.y + pipGap;
|
||||
const pipMaxX =
|
||||
remotePosition.x +
|
||||
remotePosition.width -
|
||||
dragTilePosition.width -
|
||||
pipGap;
|
||||
const pipMaxY =
|
||||
remotePosition.y +
|
||||
remotePosition.height -
|
||||
dragTilePosition.height -
|
||||
pipGap;
|
||||
|
||||
if (hoverTile) {
|
||||
// Shift the tiles into their new order
|
||||
newTiles = newTiles.map((tile) => {
|
||||
let order = tile.order;
|
||||
if (order < dragTile.order) {
|
||||
if (order >= hoverTile.order) order++;
|
||||
} else if (order > dragTile.order) {
|
||||
if (order <= hoverTile.order) order--;
|
||||
} else {
|
||||
order = hoverTile.order;
|
||||
}
|
||||
const newPipXRatio =
|
||||
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
|
||||
const newPipYRatio =
|
||||
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
|
||||
|
||||
let focused;
|
||||
if (tile === hoverTile) {
|
||||
focused = dragTile.focused;
|
||||
} else if (tile === dragTile) {
|
||||
focused = hoverTile.focused;
|
||||
} else {
|
||||
focused = tile.focused;
|
||||
}
|
||||
|
||||
return { ...tile, order, focused };
|
||||
});
|
||||
|
||||
reorderTiles(newTiles, layout);
|
||||
|
||||
setTileState((state) => ({ ...state, tiles: newTiles }));
|
||||
}
|
||||
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
|
||||
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
|
||||
}
|
||||
} else {
|
||||
const hoverTile = tiles.find(
|
||||
(tile) =>
|
||||
tile.key !== tileId &&
|
||||
isInside(cursorPosition, tilePositions[tile.order])
|
||||
);
|
||||
|
||||
if (active) {
|
||||
if (!draggingTileRef.current) {
|
||||
draggingTileRef.current = {
|
||||
key: dragTile.key,
|
||||
offsetX: dragTilePosition.x,
|
||||
offsetY: dragTilePosition.y,
|
||||
x: movement[0],
|
||||
y: movement[1],
|
||||
};
|
||||
} else {
|
||||
draggingTileRef.current.x = movement[0];
|
||||
draggingTileRef.current.y = movement[1];
|
||||
}
|
||||
if (hoverTile) {
|
||||
// Shift the tiles into their new order
|
||||
newTiles = newTiles.map((tile) => {
|
||||
let order = tile.order;
|
||||
if (order < dragTile.order) {
|
||||
if (order >= hoverTile.order) order++;
|
||||
} else if (order > dragTile.order) {
|
||||
if (order <= hoverTile.order) order--;
|
||||
} else {
|
||||
order = hoverTile.order;
|
||||
}
|
||||
|
||||
let focused;
|
||||
if (tile === hoverTile) {
|
||||
focused = dragTile.focused;
|
||||
} else if (tile === dragTile) {
|
||||
focused = hoverTile.focused;
|
||||
} else {
|
||||
focused = tile.focused;
|
||||
}
|
||||
|
||||
return { ...tile, order, focused };
|
||||
});
|
||||
|
||||
reorderTiles(newTiles, layout);
|
||||
|
||||
setTileState((state) => ({ ...state, tiles: newTiles }));
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (!draggingTileRef.current) {
|
||||
draggingTileRef.current = {
|
||||
key: dragTile.key,
|
||||
offsetX: dragTilePosition.x,
|
||||
offsetY: dragTilePosition.y,
|
||||
x: movement[0],
|
||||
y: movement[1],
|
||||
};
|
||||
} else {
|
||||
draggingTileRef.current = null;
|
||||
draggingTileRef.current.x = movement[0];
|
||||
draggingTileRef.current.y = movement[1];
|
||||
}
|
||||
} else {
|
||||
draggingTileRef.current = null;
|
||||
}
|
||||
|
||||
api.start(animate(newTiles));
|
||||
},
|
||||
{ filterTaps: true, pointer: { buttons: [1] } }
|
||||
);
|
||||
api.start(animate(newTiles));
|
||||
};
|
||||
|
||||
const onTileDragRef = useRef(onTileDrag);
|
||||
onTileDragRef.current = onTileDrag;
|
||||
|
||||
const onGridGesture = useCallback(
|
||||
(
|
||||
@ -1220,18 +1246,23 @@ export function VideoGrid({
|
||||
|
||||
return (
|
||||
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
|
||||
{springs.map((style, i) => {
|
||||
{springs.map((spring, i) => {
|
||||
const tile = tiles[i];
|
||||
const tilePosition = tilePositions[tile.order];
|
||||
|
||||
return children({
|
||||
...bindTile(tile.key),
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tilePosition.width,
|
||||
targetHeight: tilePosition.height,
|
||||
item: tile.item,
|
||||
});
|
||||
return (
|
||||
<TileWrapper
|
||||
key={tile.key}
|
||||
id={tile.key}
|
||||
onDragRef={onTileDragRef}
|
||||
targetWidth={tilePosition.width}
|
||||
targetHeight={tilePosition.height}
|
||||
item={tile.item}
|
||||
{...spring}
|
||||
>
|
||||
{children}
|
||||
</TileWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
@ -18,12 +18,10 @@ limitations under the License.
|
||||
position: absolute;
|
||||
contain: strict;
|
||||
top: 0;
|
||||
width: var(--tileWidth);
|
||||
height: var(--tileHeight);
|
||||
container-name: videoTile;
|
||||
container-type: size;
|
||||
--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;
|
||||
|
||||
|
@ -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.
|
||||
@ -14,86 +14,105 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ForwardedRef, forwardRef } from "react";
|
||||
import { animated, SpringValue } from "@react-spring/web";
|
||||
import React, { ComponentProps, forwardRef, useCallback } from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||
import { AudioButton, FullscreenButton } from "../button/Button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useSpatialMediaStream } from "./useMediaStream";
|
||||
import { useRoomMemberName } from "./useRoomMemberName";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
connectionState: ConnectionState;
|
||||
speaking?: boolean;
|
||||
audioMuted?: boolean;
|
||||
videoMuted?: boolean;
|
||||
screenshare?: boolean;
|
||||
avatar?: JSX.Element;
|
||||
mediaRef?: React.RefObject<MediaElement>;
|
||||
onOptionsPress?: () => void;
|
||||
localVolume?: number;
|
||||
hasAudio?: boolean;
|
||||
maximised?: boolean;
|
||||
fullscreen?: boolean;
|
||||
onFullscreen?: () => void;
|
||||
item: TileDescriptor;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (participant: TileDescriptor) => void;
|
||||
className?: string;
|
||||
showOptions?: boolean;
|
||||
isLocal?: boolean;
|
||||
disableSpeakingIndicator?: boolean;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
showSpeakingIndicator: boolean;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
getAvatar: (
|
||||
roomMember: RoomMember,
|
||||
width: number,
|
||||
height: number
|
||||
) => JSX.Element;
|
||||
audioContext: AudioContext;
|
||||
audioDestination: AudioNode;
|
||||
}
|
||||
|
||||
export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
connectionState,
|
||||
speaking,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
screenshare,
|
||||
avatar,
|
||||
mediaRef,
|
||||
onOptionsPress,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
item,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
className,
|
||||
showOptions,
|
||||
isLocal,
|
||||
// TODO: disableSpeakingIndicator is not used atm.
|
||||
disableSpeakingIndicator,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
...rest
|
||||
showSpeakingIndicator,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
},
|
||||
ref
|
||||
tileRef1
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const screenshare = purpose === SDPStreamMetadataPurpose.Screenshare;
|
||||
const { rawDisplayName: name } = useRoomMemberName(item.member);
|
||||
|
||||
const [tileRef2, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
|
||||
const tileRef = useMergedRefs(tileRef1, tileRef2);
|
||||
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = videoTileSettingsModalState.open;
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
|
||||
const toolbarButtons: JSX.Element[] = [];
|
||||
if (connectionState == ConnectionState.Connected && !isLocal) {
|
||||
if (item.connectionState == ConnectionState.Connected && !isLocal) {
|
||||
if (hasAudio) {
|
||||
toolbarButtons.push(
|
||||
<AudioButton
|
||||
@ -111,14 +130,14 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
key="fullscreen"
|
||||
className={styles.button}
|
||||
fullscreen={fullscreen}
|
||||
onPress={onFullscreen}
|
||||
onPress={onFullscreenCallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let caption: string;
|
||||
switch (connectionState) {
|
||||
switch (item.connectionState) {
|
||||
case ConnectionState.EstablishingCall:
|
||||
caption = t("{{name}} (Connecting...)", { name });
|
||||
break;
|
||||
@ -131,68 +150,65 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
break;
|
||||
}
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.videoTile, className, {
|
||||
[styles.isLocal]: isLocal,
|
||||
[styles.speaking]: speaking,
|
||||
[styles.muted]: audioMuted,
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore React does in fact support assigning custom properties,
|
||||
// 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 && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{videoMuted && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
{avatar}
|
||||
</>
|
||||
)}
|
||||
{!maximised &&
|
||||
(screenshare ? (
|
||||
<div className={styles.presenterLabel}>
|
||||
<span>{t("{{name}} is presenting", { name })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{
|
||||
/* If the user is speaking, it's safe to say they're unmuted.
|
||||
<>
|
||||
<animated.div
|
||||
className={classNames(styles.videoTile, className, {
|
||||
[styles.isLocal]: isLocal,
|
||||
[styles.speaking]: speaking && showSpeakingIndicator,
|
||||
[styles.muted]: audioMuted,
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
ref={tileRef}
|
||||
data-testid="videoTile"
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{videoMuted && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
{getAvatar(item.member, targetWidth, targetHeight)}
|
||||
</>
|
||||
)}
|
||||
{!maximised &&
|
||||
(screenshare ? (
|
||||
<div className={styles.presenterLabel}>
|
||||
<span>{t("{{name}} is presenting", { name })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{
|
||||
/* If the user is speaking, it's safe to say they're unmuted.
|
||||
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. */
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
@ -1,157 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 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 { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
import React, { FC, memo, RefObject } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { SpringValue } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useSpatialMediaStream } from "./useMediaStream";
|
||||
import { useRoomMemberName } from "./useRoomMemberName";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
interface Props {
|
||||
item: TileDescriptor;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
getAvatar: (
|
||||
roomMember: RoomMember,
|
||||
width: number,
|
||||
height: number
|
||||
) => JSX.Element;
|
||||
audioContext: AudioContext;
|
||||
audioDestination: AudioNode;
|
||||
disableSpeakingIndicator: boolean;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (item: TileDescriptor) => void;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
onDragRef?: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const VideoTileContainer: FC<Props> = memo(
|
||||
({
|
||||
item,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
disableSpeakingIndicator,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
onDragRef,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const { rawDisplayName } = useRoomMemberName(item.member);
|
||||
|
||||
const [tileRef, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
|
||||
useDrag((state) => onDragRef?.current!(item.id, state), {
|
||||
target: tileRef,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = () => {
|
||||
videoTileSettingsModalState.open();
|
||||
};
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoTile
|
||||
isLocal={isLocal}
|
||||
speaking={speaking && !disableSpeakingIndicator}
|
||||
audioMuted={audioMuted}
|
||||
videoMuted={videoMuted}
|
||||
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
|
||||
name={rawDisplayName}
|
||||
connectionState={item.connectionState}
|
||||
ref={tileRef}
|
||||
mediaRef={mediaRef}
|
||||
avatar={
|
||||
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
|
||||
}
|
||||
onOptionsPress={onOptionsPress}
|
||||
localVolume={localVolume}
|
||||
hasAudio={hasAudio}
|
||||
maximised={maximised}
|
||||
fullscreen={fullscreen}
|
||||
onFullscreen={onFullscreenCallback}
|
||||
{...rest}
|
||||
/>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
Loading…
Reference in New Issue
Block a user