Merge pull request #1104 from robintown/decouple-grid
Decouple video grid from video tile components
This commit is contained in:
commit
bde13e0fab
10 changed files with 426 additions and 531 deletions
|
@ -82,17 +82,21 @@ limitations under the License.
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar {
|
/* CSS makes us put a condition here, even though all we want to do is
|
||||||
position: absolute;
|
unconditionally select the container so we can use cqmin units */
|
||||||
top: 50%;
|
@container videoTile (width > 0) {
|
||||||
left: 50%;
|
.avatar {
|
||||||
transform: translate(-50%, -50%);
|
position: absolute;
|
||||||
/* To make avatars scale smoothly with their tiles during animations, we
|
top: 50%;
|
||||||
override the styles set on the element */
|
left: 50%;
|
||||||
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
|
transform: translate(-50%, -50%);
|
||||||
width: var(--avatarSize) !important;
|
/* To make avatars scale smoothly with their tiles during animations, we
|
||||||
height: var(--avatarSize) !important;
|
override the styles set on the element */
|
||||||
border-radius: 10000px !important;
|
--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) {
|
@media (min-height: 300px) {
|
||||||
|
|
|
@ -44,12 +44,7 @@ import {
|
||||||
RoomHeaderInfo,
|
RoomHeaderInfo,
|
||||||
VersionMismatchWarning,
|
VersionMismatchWarning,
|
||||||
} from "../Header";
|
} from "../Header";
|
||||||
import {
|
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
||||||
VideoGrid,
|
|
||||||
useVideoGridLayout,
|
|
||||||
ChildrenProperties,
|
|
||||||
} from "../video-grid/VideoGrid";
|
|
||||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
|
||||||
import { GroupCallInspector } from "./GroupCallInspector";
|
import { GroupCallInspector } from "./GroupCallInspector";
|
||||||
import { GridLayoutMenu } from "./GridLayoutMenu";
|
import { GridLayoutMenu } from "./GridLayoutMenu";
|
||||||
import { Avatar } from "../Avatar";
|
import { Avatar } from "../Avatar";
|
||||||
|
@ -77,6 +72,7 @@ import { SettingsModal } from "../settings/SettingsModal";
|
||||||
import { InviteModal } from "./InviteModal";
|
import { InviteModal } from "./InviteModal";
|
||||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||||
|
import { VideoTile } from "../video-grid/VideoTile";
|
||||||
|
|
||||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||||
|
@ -303,7 +299,7 @@ export function InCallView({
|
||||||
}
|
}
|
||||||
if (maximisedParticipant) {
|
if (maximisedParticipant) {
|
||||||
return (
|
return (
|
||||||
<VideoTileContainer
|
<VideoTile
|
||||||
targetHeight={bounds.height}
|
targetHeight={bounds.height}
|
||||||
targetWidth={bounds.width}
|
targetWidth={bounds.width}
|
||||||
key={maximisedParticipant.id}
|
key={maximisedParticipant.id}
|
||||||
|
@ -311,10 +307,10 @@ export function InCallView({
|
||||||
getAvatar={renderAvatar}
|
getAvatar={renderAvatar}
|
||||||
audioContext={audioContext}
|
audioContext={audioContext}
|
||||||
audioDestination={audioDestination}
|
audioDestination={audioDestination}
|
||||||
disableSpeakingIndicator={true}
|
|
||||||
maximised={Boolean(maximisedParticipant)}
|
maximised={Boolean(maximisedParticipant)}
|
||||||
fullscreen={maximisedParticipant === fullscreenParticipant}
|
fullscreen={maximisedParticipant === fullscreenParticipant}
|
||||||
onFullscreen={toggleFullscreen}
|
onFullscreen={toggleFullscreen}
|
||||||
|
showSpeakingIndicator={false}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -325,17 +321,16 @@ export function InCallView({
|
||||||
layout={layout}
|
layout={layout}
|
||||||
disableAnimations={prefersReducedMotion || isSafari}
|
disableAnimations={prefersReducedMotion || isSafari}
|
||||||
>
|
>
|
||||||
{({ item, ...rest }: ChildrenProperties) => (
|
{(props) => (
|
||||||
<VideoTileContainer
|
<VideoTile
|
||||||
item={item}
|
|
||||||
getAvatar={renderAvatar}
|
getAvatar={renderAvatar}
|
||||||
audioContext={audioContext}
|
audioContext={audioContext}
|
||||||
audioDestination={audioDestination}
|
audioDestination={audioDestination}
|
||||||
disableSpeakingIndicator={items.length < 3}
|
|
||||||
maximised={false}
|
maximised={false}
|
||||||
fullscreen={false}
|
fullscreen={false}
|
||||||
onFullscreen={toggleFullscreen}
|
onFullscreen={toggleFullscreen}
|
||||||
{...rest}
|
showSpeakingIndicator={items.length > 2}
|
||||||
|
{...props}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
|
@ -21,14 +21,14 @@ import { MutableRefObject, RefCallback, useCallback } from "react";
|
||||||
* same DOM node.
|
* same DOM node.
|
||||||
*/
|
*/
|
||||||
export const useMergedRefs = <T>(
|
export const useMergedRefs = <T>(
|
||||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
|
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
|
||||||
): RefCallback<T | null> =>
|
): RefCallback<T | null> =>
|
||||||
useCallback(
|
useCallback(
|
||||||
(value) =>
|
(value) =>
|
||||||
refs.forEach((ref) => {
|
refs.forEach((ref) => {
|
||||||
if (typeof ref === "function") {
|
if (typeof ref === "function") {
|
||||||
ref(value);
|
ref(value);
|
||||||
} else {
|
} else if (ref !== null) {
|
||||||
ref.current = value;
|
ref.current = value;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -45,6 +45,7 @@ import {
|
||||||
cycleTileSize,
|
cycleTileSize,
|
||||||
appendItems,
|
appendItems,
|
||||||
} from "./model";
|
} from "./model";
|
||||||
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
|
||||||
interface GridState extends Grid {
|
interface GridState extends Grid {
|
||||||
/**
|
/**
|
||||||
|
@ -452,16 +453,19 @@ export const NewVideoGrid: FC<Props> = ({
|
||||||
>
|
>
|
||||||
{slots}
|
{slots}
|
||||||
</div>
|
</div>
|
||||||
{tileTransitions((style, tile) =>
|
{tileTransitions((spring, tile) => (
|
||||||
children({
|
<TileWrapper
|
||||||
...style,
|
key={tile.item.id}
|
||||||
key: tile.item.id,
|
id={tile.item.id}
|
||||||
targetWidth: tile.width,
|
onDragRef={onTileDragRef}
|
||||||
targetHeight: tile.height,
|
targetWidth={tile.width}
|
||||||
item: tile.item,
|
targetHeight={tile.height}
|
||||||
onDragRef: onTileDragRef,
|
item={tile.item}
|
||||||
})
|
{...spring}
|
||||||
)}
|
>
|
||||||
|
{children}
|
||||||
|
</TileWrapper>
|
||||||
|
))}
|
||||||
</div>
|
</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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
|
import React, {
|
||||||
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
|
ComponentProps,
|
||||||
|
Key,
|
||||||
|
Ref,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
|
EventTypes,
|
||||||
|
FullGestureState,
|
||||||
|
Handler,
|
||||||
|
useGesture,
|
||||||
|
} from "@use-gesture/react";
|
||||||
|
import {
|
||||||
|
animated,
|
||||||
SpringRef,
|
SpringRef,
|
||||||
SpringValue,
|
|
||||||
SpringValues,
|
SpringValues,
|
||||||
useSprings,
|
useSprings,
|
||||||
} from "@react-spring/web";
|
} from "@react-spring/web";
|
||||||
import useMeasure from "react-use-measure";
|
import useMeasure from "react-use-measure";
|
||||||
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
|
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 styles from "./VideoGrid.module.css";
|
||||||
import { Layout } from "../room/GridLayoutMenu";
|
import { Layout } from "../room/GridLayoutMenu";
|
||||||
import { TileDescriptor } from "./TileDescriptor";
|
import { TileDescriptor } from "./TileDescriptor";
|
||||||
|
import { TileWrapper } from "./TileWrapper";
|
||||||
|
|
||||||
interface TilePosition {
|
interface TilePosition {
|
||||||
x: number;
|
x: number;
|
||||||
|
@ -39,7 +52,7 @@ interface TilePosition {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Tile {
|
interface Tile {
|
||||||
key: Key;
|
key: string;
|
||||||
order: number;
|
order: number;
|
||||||
item: TileDescriptor;
|
item: TileDescriptor;
|
||||||
remove: boolean;
|
remove: boolean;
|
||||||
|
@ -717,20 +730,18 @@ interface DragTileData {
|
||||||
y: number;
|
y: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChildrenProperties extends ReactDOMAttributes {
|
export interface ChildrenProperties {
|
||||||
key: Key;
|
ref: Ref<HTMLElement>;
|
||||||
|
style: ComponentProps<typeof animated.div>["style"];
|
||||||
|
/**
|
||||||
|
* The width this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
targetWidth: number;
|
targetWidth: number;
|
||||||
|
/**
|
||||||
|
* The height this tile will have once its animations have settled.
|
||||||
|
*/
|
||||||
targetHeight: number;
|
targetHeight: number;
|
||||||
item: TileDescriptor;
|
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 {
|
export interface VideoGridProps {
|
||||||
|
@ -1063,117 +1074,132 @@ export function VideoGrid({
|
||||||
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
|
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
|
||||||
);
|
);
|
||||||
|
|
||||||
const bindTile = useDrag(
|
// Callback for useDrag. We could call useDrag here, but the default
|
||||||
({ args: [key], active, xy, movement, tap, last, event }) => {
|
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||||
event.preventDefault();
|
// 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) {
|
if (tap) {
|
||||||
onTap(key);
|
onTap(tileId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layout !== "freedom") return;
|
if (layout !== "freedom") return;
|
||||||
|
|
||||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
|
const dragTileIndex = tiles.findIndex((tile) => tile.key === tileId);
|
||||||
const dragTile = tiles[dragTileIndex];
|
const dragTile = tiles[dragTileIndex];
|
||||||
const dragTilePosition = tilePositions[dragTile.order];
|
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)) {
|
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
|
// We're in 1:1 mode, so only the local tile should be draggable
|
||||||
if (!dragTile.item.isLocal) return;
|
if (!dragTile.item.isLocal) return;
|
||||||
|
|
||||||
// Position should only update on the very last event, to avoid
|
// Position should only update on the very last event, to avoid
|
||||||
// compounding the offset on every drag event
|
// compounding the offset on every drag event
|
||||||
if (last) {
|
if (last) {
|
||||||
const remotePosition = tilePositions[1];
|
const remotePosition = tilePositions[1];
|
||||||
|
|
||||||
const pipGap = getPipGap(
|
const pipGap = getPipGap(
|
||||||
gridBounds.width / gridBounds.height,
|
gridBounds.width / gridBounds.height,
|
||||||
gridBounds.width
|
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 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) {
|
const newPipXRatio =
|
||||||
// Shift the tiles into their new order
|
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
|
||||||
newTiles = newTiles.map((tile) => {
|
const newPipYRatio =
|
||||||
let order = tile.order;
|
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
|
||||||
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;
|
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
|
||||||
if (tile === hoverTile) {
|
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const hoverTile = tiles.find(
|
||||||
|
(tile) =>
|
||||||
|
tile.key !== tileId &&
|
||||||
|
isInside(cursorPosition, tilePositions[tile.order])
|
||||||
|
);
|
||||||
|
|
||||||
if (active) {
|
if (hoverTile) {
|
||||||
if (!draggingTileRef.current) {
|
// Shift the tiles into their new order
|
||||||
draggingTileRef.current = {
|
newTiles = newTiles.map((tile) => {
|
||||||
key: dragTile.key,
|
let order = tile.order;
|
||||||
offsetX: dragTilePosition.x,
|
if (order < dragTile.order) {
|
||||||
offsetY: dragTilePosition.y,
|
if (order >= hoverTile.order) order++;
|
||||||
x: movement[0],
|
} else if (order > dragTile.order) {
|
||||||
y: movement[1],
|
if (order <= hoverTile.order) order--;
|
||||||
};
|
} else {
|
||||||
} else {
|
order = hoverTile.order;
|
||||||
draggingTileRef.current.x = movement[0];
|
}
|
||||||
draggingTileRef.current.y = movement[1];
|
|
||||||
}
|
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 {
|
} else {
|
||||||
draggingTileRef.current = null;
|
draggingTileRef.current.x = movement[0];
|
||||||
|
draggingTileRef.current.y = movement[1];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
draggingTileRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
api.start(animate(newTiles));
|
api.start(animate(newTiles));
|
||||||
},
|
};
|
||||||
{ filterTaps: true, pointer: { buttons: [1] } }
|
|
||||||
);
|
const onTileDragRef = useRef(onTileDrag);
|
||||||
|
onTileDragRef.current = onTileDrag;
|
||||||
|
|
||||||
const onGridGesture = useCallback(
|
const onGridGesture = useCallback(
|
||||||
(
|
(
|
||||||
|
@ -1220,18 +1246,23 @@ export function VideoGrid({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
|
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
|
||||||
{springs.map((style, i) => {
|
{springs.map((spring, i) => {
|
||||||
const tile = tiles[i];
|
const tile = tiles[i];
|
||||||
const tilePosition = tilePositions[tile.order];
|
const tilePosition = tilePositions[tile.order];
|
||||||
|
|
||||||
return children({
|
return (
|
||||||
...bindTile(tile.key),
|
<TileWrapper
|
||||||
...style,
|
key={tile.key}
|
||||||
key: tile.item.id,
|
id={tile.key}
|
||||||
targetWidth: tilePosition.width,
|
onDragRef={onTileDragRef}
|
||||||
targetHeight: tilePosition.height,
|
targetWidth={tilePosition.width}
|
||||||
item: tile.item,
|
targetHeight={tilePosition.height}
|
||||||
});
|
item={tile.item}
|
||||||
|
{...spring}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</TileWrapper>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -18,12 +18,10 @@ limitations under the License.
|
||||||
position: absolute;
|
position: absolute;
|
||||||
contain: strict;
|
contain: strict;
|
||||||
top: 0;
|
top: 0;
|
||||||
width: var(--tileWidth);
|
container-name: videoTile;
|
||||||
height: var(--tileHeight);
|
container-type: size;
|
||||||
--tileRadius: 8px;
|
--tileRadius: 8px;
|
||||||
border-radius: var(--tileRadius);
|
border-radius: var(--tileRadius);
|
||||||
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
|
|
||||||
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
cursor: pointer;
|
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");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with 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.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ForwardedRef, forwardRef } from "react";
|
import React, { ComponentProps, forwardRef, useCallback } from "react";
|
||||||
import { animated, SpringValue } from "@react-spring/web";
|
import { animated } from "@react-spring/web";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
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 styles from "./VideoTile.module.css";
|
||||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||||
import { AudioButton, FullscreenButton } from "../button/Button";
|
import { AudioButton, FullscreenButton } from "../button/Button";
|
||||||
import { ConnectionState } from "../room/useGroupCall";
|
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 {
|
interface Props {
|
||||||
name: string;
|
item: TileDescriptor;
|
||||||
connectionState: ConnectionState;
|
maximised: boolean;
|
||||||
speaking?: boolean;
|
fullscreen: boolean;
|
||||||
audioMuted?: boolean;
|
onFullscreen: (participant: TileDescriptor) => void;
|
||||||
videoMuted?: boolean;
|
|
||||||
screenshare?: boolean;
|
|
||||||
avatar?: JSX.Element;
|
|
||||||
mediaRef?: React.RefObject<MediaElement>;
|
|
||||||
onOptionsPress?: () => void;
|
|
||||||
localVolume?: number;
|
|
||||||
hasAudio?: boolean;
|
|
||||||
maximised?: boolean;
|
|
||||||
fullscreen?: boolean;
|
|
||||||
onFullscreen?: () => void;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
showOptions?: boolean;
|
showSpeakingIndicator: boolean;
|
||||||
isLocal?: boolean;
|
style?: ComponentProps<typeof animated.div>["style"];
|
||||||
disableSpeakingIndicator?: boolean;
|
targetWidth: number;
|
||||||
opacity?: SpringValue<number>;
|
targetHeight: number;
|
||||||
scale?: SpringValue<number>;
|
getAvatar: (
|
||||||
shadow?: SpringValue<number>;
|
roomMember: RoomMember,
|
||||||
shadowSpread?: SpringValue<number>;
|
width: number,
|
||||||
zIndex?: SpringValue<number>;
|
height: number
|
||||||
x?: SpringValue<number>;
|
) => JSX.Element;
|
||||||
y?: SpringValue<number>;
|
audioContext: AudioContext;
|
||||||
width?: SpringValue<number>;
|
audioDestination: AudioNode;
|
||||||
height?: SpringValue<number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VideoTile = forwardRef<HTMLElement, Props>(
|
export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
name,
|
item,
|
||||||
connectionState,
|
|
||||||
speaking,
|
|
||||||
audioMuted,
|
|
||||||
videoMuted,
|
|
||||||
screenshare,
|
|
||||||
avatar,
|
|
||||||
mediaRef,
|
|
||||||
onOptionsPress,
|
|
||||||
localVolume,
|
|
||||||
hasAudio,
|
|
||||||
maximised,
|
maximised,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
onFullscreen,
|
onFullscreen,
|
||||||
className,
|
className,
|
||||||
showOptions,
|
showSpeakingIndicator,
|
||||||
isLocal,
|
style,
|
||||||
// TODO: disableSpeakingIndicator is not used atm.
|
targetWidth,
|
||||||
disableSpeakingIndicator,
|
targetHeight,
|
||||||
opacity,
|
getAvatar,
|
||||||
scale,
|
audioContext,
|
||||||
shadow,
|
audioDestination,
|
||||||
shadowSpread,
|
|
||||||
zIndex,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
...rest
|
|
||||||
},
|
},
|
||||||
ref
|
tileRef1
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
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[] = [];
|
const toolbarButtons: JSX.Element[] = [];
|
||||||
if (connectionState == ConnectionState.Connected && !isLocal) {
|
if (item.connectionState == ConnectionState.Connected && !isLocal) {
|
||||||
if (hasAudio) {
|
if (hasAudio) {
|
||||||
toolbarButtons.push(
|
toolbarButtons.push(
|
||||||
<AudioButton
|
<AudioButton
|
||||||
|
@ -111,14 +130,14 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||||
key="fullscreen"
|
key="fullscreen"
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
fullscreen={fullscreen}
|
fullscreen={fullscreen}
|
||||||
onPress={onFullscreen}
|
onPress={onFullscreenCallback}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let caption: string;
|
let caption: string;
|
||||||
switch (connectionState) {
|
switch (item.connectionState) {
|
||||||
case ConnectionState.EstablishingCall:
|
case ConnectionState.EstablishingCall:
|
||||||
caption = t("{{name}} (Connecting...)", { name });
|
caption = t("{{name}} (Connecting...)", { name });
|
||||||
break;
|
break;
|
||||||
|
@ -131,68 +150,65 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||||
|
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<animated.div
|
<>
|
||||||
className={classNames(styles.videoTile, className, {
|
<animated.div
|
||||||
[styles.isLocal]: isLocal,
|
className={classNames(styles.videoTile, className, {
|
||||||
[styles.speaking]: speaking,
|
[styles.isLocal]: isLocal,
|
||||||
[styles.muted]: audioMuted,
|
[styles.speaking]: speaking && showSpeakingIndicator,
|
||||||
[styles.screenshare]: screenshare,
|
[styles.muted]: audioMuted,
|
||||||
[styles.maximised]: maximised,
|
[styles.screenshare]: screenshare,
|
||||||
})}
|
[styles.maximised]: maximised,
|
||||||
style={{
|
})}
|
||||||
opacity,
|
style={style}
|
||||||
scale,
|
ref={tileRef}
|
||||||
zIndex,
|
data-testid="videoTile"
|
||||||
x,
|
>
|
||||||
y,
|
{toolbarButtons.length > 0 && !maximised && (
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||||
// @ts-ignore React does in fact support assigning custom properties,
|
)}
|
||||||
// but React's types say no
|
{videoMuted && (
|
||||||
"--tileWidth": width?.to((w) => `${w}px`),
|
<>
|
||||||
"--tileHeight": height?.to((h) => `${h}px`),
|
<div className={styles.videoMutedOverlay} />
|
||||||
"--tileShadow": shadow?.to((s) => `${s}px`),
|
{getAvatar(item.member, targetWidth, targetHeight)}
|
||||||
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
|
</>
|
||||||
}}
|
)}
|
||||||
ref={ref as ForwardedRef<HTMLDivElement>}
|
{!maximised &&
|
||||||
data-testid="videoTile"
|
(screenshare ? (
|
||||||
{...rest}
|
<div className={styles.presenterLabel}>
|
||||||
>
|
<span>{t("{{name}} is presenting", { name })}</span>
|
||||||
{toolbarButtons.length > 0 && !maximised && (
|
</div>
|
||||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
) : (
|
||||||
)}
|
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||||
{videoMuted && (
|
{
|
||||||
<>
|
/* If the user is speaking, it's safe to say they're unmuted.
|
||||||
<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.
|
|
||||||
Mute state is currently sent over to-device messages, which
|
Mute state is currently sent over to-device messages, which
|
||||||
aren't quite real-time, so this is an important kludge to make
|
aren't quite real-time, so this is an important kludge to make
|
||||||
sure no one appears muted when they've clearly begun talking. */
|
sure no one appears muted when they've clearly begun talking. */
|
||||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||||
}
|
}
|
||||||
<span data-testid="videoTile_caption" title={caption}>
|
<span data-testid="videoTile_caption" title={caption}>
|
||||||
{caption}
|
{caption}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<video
|
<video
|
||||||
data-testid="videoTile_video"
|
data-testid="videoTile_video"
|
||||||
ref={mediaRef}
|
ref={mediaRef}
|
||||||
playsInline
|
playsInline
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
/>
|
/>
|
||||||
</animated.div>
|
</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…
Add table
Reference in a new issue