From 1207ecc9d78160d6f6d8dffd9158cfe92ceafd59 Mon Sep 17 00:00:00 2001 From: Robin Townsend Date: Mon, 12 Jun 2023 18:06:18 -0400 Subject: [PATCH] Decouple video grid from video tile components This is an attempt to address the feedback in https://github.com/vector-im/element-call/pull/1099#discussion_r1226863404 that the video grid and video tile components have become too tightly coupled. After this change, the only requirements that the video grid makes of its child components are: - They accept ref, style, and item props - They attach the ref and styles to a react-spring animated element Note: I removed the video grid Storybook file, because I'm not aware of anyone using Storybook for development of Element Call beyond Robert, and it would take some effort to fix to work with these changes. --- src/room/InCallView.module.css | 26 +-- src/room/InCallView.tsx | 21 +- src/useMergedRefs.ts | 4 +- src/video-grid/NewVideoGrid.tsx | 24 ++- src/video-grid/TileWrapper.tsx | 101 ++++++++++ src/video-grid/VideoGrid.stories.tsx | 97 --------- src/video-grid/VideoGrid.tsx | 273 ++++++++++++++------------ src/video-grid/VideoTile.module.css | 6 +- src/video-grid/VideoTile.tsx | 248 ++++++++++++----------- src/video-grid/VideoTileContainer.tsx | 157 --------------- 10 files changed, 426 insertions(+), 531 deletions(-) create mode 100644 src/video-grid/TileWrapper.tsx delete mode 100644 src/video-grid/VideoGrid.stories.tsx delete mode 100644 src/video-grid/VideoTileContainer.tsx diff --git a/src/room/InCallView.module.css b/src/room/InCallView.module.css index 0e510a7..5638b10 100644 --- a/src/room/InCallView.module.css +++ b/src/room/InCallView.module.css @@ -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) { diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 3446d4a..bf38693 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -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 ( - ); } @@ -325,17 +321,16 @@ export function InCallView({ layout={layout} disableAnimations={prefersReducedMotion || isSafari} > - {({ item, ...rest }: ChildrenProperties) => ( - ( + 2} + {...props} /> )} diff --git a/src/useMergedRefs.ts b/src/useMergedRefs.ts index 3fab929..8139c15 100644 --- a/src/useMergedRefs.ts +++ b/src/useMergedRefs.ts @@ -21,14 +21,14 @@ import { MutableRefObject, RefCallback, useCallback } from "react"; * same DOM node. */ export const useMergedRefs = ( - ...refs: (MutableRefObject | RefCallback)[] + ...refs: (MutableRefObject | RefCallback | null)[] ): RefCallback => useCallback( (value) => refs.forEach((ref) => { if (typeof ref === "function") { ref(value); - } else { + } else if (ref !== null) { ref.current = value; } }), diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 3e74cdd..7e7b916 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -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 = ({ > {slots} - {tileTransitions((style, tile) => - children({ - ...style, - key: tile.item.id, - targetWidth: tile.width, - targetHeight: tile.height, - item: tile.item, - onDragRef: onTileDragRef, - }) - )} + {tileTransitions((spring, tile) => ( + + {children} + + ))} ); }; diff --git a/src/video-grid/TileWrapper.tsx b/src/video-grid/TileWrapper.tsx new file mode 100644 index 0000000..6eca3ed --- /dev/null +++ b/src/video-grid/TileWrapper.tsx @@ -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>[0] + ) => void + >; + targetWidth: number; + targetHeight: number; + item: TileDescriptor; + opacity: SpringValue; + scale: SpringValue; + shadow: SpringValue; + shadowSpread: SpringValue; + zIndex: SpringValue; + x: SpringValue; + y: SpringValue; + width: SpringValue; + height: SpringValue; + 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 = memo( + ({ + id, + onDragRef, + targetWidth, + targetHeight, + item, + opacity, + scale, + shadow, + shadowSpread, + zIndex, + x, + y, + width, + height, + children, + }) => { + const ref = useRef(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, + })} + + ); + } +); diff --git a/src/video-grid/VideoGrid.stories.tsx b/src/video-grid/VideoGrid.stories.tsx deleted file mode 100644 index 859e593..0000000 --- a/src/video-grid/VideoGrid.stories.tsx +++ /dev/null @@ -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 ( - <> -
- - {participantCount < 12 && ( - - )} - {participantCount > 0 && ( - - )} -
-
- - {({ item, ...rest }) => ( - - )} - -
- - ); -}; - -ParticipantsTest.args = { - layout: "freedom", - participantCount: 1, -}; diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index 5fd6163..0d70663 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -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; + style: ComponentProps["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; - scale: SpringValue; - shadow: SpringValue; - zIndex: SpringValue; - x: SpringValue; - y: SpringValue; - width: SpringValue; - height: SpringValue; - [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>[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 (
- {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 ( + + {children} + + ); })}
); diff --git a/src/video-grid/VideoTile.module.css b/src/video-grid/VideoTile.module.css index 8c2f7af..1627160 100644 --- a/src/video-grid/VideoTile.module.css +++ b/src/video-grid/VideoTile.module.css @@ -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; diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index b9a2453..2f92cb4 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -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; - 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; - scale?: SpringValue; - shadow?: SpringValue; - shadowSpread?: SpringValue; - zIndex?: SpringValue; - x?: SpringValue; - y?: SpringValue; - width?: SpringValue; - height?: SpringValue; + showSpeakingIndicator: boolean; + style?: ComponentProps["style"]; + targetWidth: number; + targetHeight: number; + getAvatar: ( + roomMember: RoomMember, + width: number, + height: number + ) => JSX.Element; + audioContext: AudioContext; + audioDestination: AudioNode; } export const VideoTile = forwardRef( ( { - 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( ( 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( break; } + // Firefox doesn't respect the disablePictureInPicture attribute + // https://bugzilla.mozilla.org/show_bug.cgi?id=1611831 + return ( - `${w}px`), - "--tileHeight": height?.to((h) => `${h}px`), - "--tileShadow": shadow?.to((s) => `${s}px`), - "--tileShadowSpread": shadowSpread?.to((s) => `${s}px`), - }} - ref={ref as ForwardedRef} - data-testid="videoTile" - {...rest} - > - {toolbarButtons.length > 0 && !maximised && ( -
{toolbarButtons}
- )} - {videoMuted && ( - <> -
- {avatar} - - )} - {!maximised && - (screenshare ? ( -
- {t("{{name}} is presenting", { name })} -
- ) : ( -
- { - /* If the user is speaking, it's safe to say they're unmuted. + <> + + {toolbarButtons.length > 0 && !maximised && ( +
{toolbarButtons}
+ )} + {videoMuted && ( + <> +
+ {getAvatar(item.member, targetWidth, targetHeight)} + + )} + {!maximised && + (screenshare ? ( +
+ {t("{{name}} is presenting", { name })} +
+ ) : ( +
+ { + /* 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 ? : - } - - {caption} - -
- ))} -
+ ))} +
+ {videoTileSettingsModalState.isOpen && !maximised && item.callFeed && ( + + )} + ); } ); diff --git a/src/video-grid/VideoTileContainer.tsx b/src/video-grid/VideoTileContainer.tsx deleted file mode 100644 index f3f5059..0000000 --- a/src/video-grid/VideoTileContainer.tsx +++ /dev/null @@ -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; - scale?: SpringValue; - shadow?: SpringValue; - shadowSpread?: SpringValue; - zIndex?: SpringValue; - x?: SpringValue; - y?: SpringValue; - width?: SpringValue; - height?: SpringValue; - onDragRef?: RefObject< - ( - tileId: string, - state: Parameters>[0] - ) => void - >; -} - -export const VideoTileContainer: FC = 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 ( - <> - - {videoTileSettingsModalState.isOpen && !maximised && item.callFeed && ( - - )} - - ); - } -);