diff --git a/src/useMergedRefs.ts b/src/useMergedRefs.ts new file mode 100644 index 00000000..9931505d --- /dev/null +++ b/src/useMergedRefs.ts @@ -0,0 +1,16 @@ +import { MutableRefObject, RefCallback, useCallback } from "react"; + +export const useMergedRefs = ( + ...refs: (MutableRefObject | RefCallback)[] +): RefCallback => + useCallback( + (value) => + refs.forEach((ref) => { + if (typeof ref === "function") { + ref(value); + } else { + ref.current = value; + } + }), + refs + ); diff --git a/src/video-grid/NewVideoGrid.tsx b/src/video-grid/NewVideoGrid.tsx index 13d1860f..a916f681 100644 --- a/src/video-grid/NewVideoGrid.tsx +++ b/src/video-grid/NewVideoGrid.tsx @@ -1,5 +1,5 @@ import { SpringRef, TransitionFn, useTransition } from "@react-spring/web"; -import { useDrag, useScroll } from "@use-gesture/react"; +import { EventTypes, Handler, useScroll } from "@use-gesture/react"; import React, { FC, ReactNode, @@ -15,6 +15,7 @@ import { VideoGridProps as Props } from "./VideoGrid"; import { useReactiveState } from "../useReactiveState"; import TinyQueue from "tinyqueue"; import { zipWith } from "lodash"; +import { useMergedRefs } from "../useMergedRefs"; interface Cell { /** @@ -66,8 +67,10 @@ interface TileSpring { interface DragState { tileId: string; - x: number; - y: number; + tileX: number; + tileY: number; + cursorX: number; + cursorY: number; } const dijkstra = (g: Grid): number[] => { @@ -377,7 +380,10 @@ export const NewVideoGrid: FC = ({ }) => { const [slotGrid, setSlotGrid] = useState(null); const [slotGridGeneration, setSlotGridGeneration] = useState(0); - const [gridRef, gridBounds] = useMeasure(); + + const [gridRef1, gridBounds] = useMeasure(); + const gridRef2 = useRef(null); + const gridRef = useMergedRefs(gridRef1, gridRef2); useEffect(() => { if (slotGrid !== null) { @@ -549,23 +555,21 @@ export const NewVideoGrid: FC = ({ }; }, [grid]); - const animateDraggedTile = (endOfGesture: boolean) => - springRef.start((_i, controller) => { - const { tileId, x, y } = dragState.current!; + const animateDraggedTile = (endOfGesture: boolean) => { + const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!; + const tile = tiles.find((t) => t.item.id === tileId)!; - // react-spring appears to not update a controller's item as long as the - // key remains stable, so we can use it to look up the tile's ID but not - // its position + springRef.start((_i, controller) => { if ((controller.item as Tile).item.id === tileId) { if (endOfGesture) { - const tile = tiles.find((t) => t.item.id === tileId)!; - return { scale: 1, zIndex: 1, shadow: 1, x: tile.x, y: tile.y, + width: tile.width, + height: tile.height, immediate: disableAnimations || ((key) => key === "zIndex"), // Allow the tile's position to settle before pushing its // z-index back down @@ -576,8 +580,8 @@ export const NewVideoGrid: FC = ({ scale: 1.1, zIndex: 2, shadow: 15, - x, - y, + x: tileX, + y: tileY, immediate: disableAnimations || ((key) => key === "zIndex" || key === "x" || key === "y"), @@ -588,41 +592,78 @@ export const NewVideoGrid: FC = ({ } }); - const bindTile = useDrag( - ({ tap, args, delta: [dx, dy], last }) => { - const tileId = args[0] as string; + const overTile = tiles.find( + (t) => + cursorX >= t.x && + cursorX < t.x + t.width && + cursorY >= t.y && + cursorY < t.y + t.height + ); + if (overTile !== undefined && overTile.item.id !== tileId) { + setGrid((g) => ({ + ...g, + cells: g.cells.map((c) => { + if (c?.item === overTile.item) return { ...c, item: tile.item }; + if (c?.item === tile.item) return { ...c, item: overTile.item }; + return c; + }), + })); + } + }; - if (tap) { - setGrid((g) => cycleTileSize(tileId, g)); - } else { - const tileSpring = springRef.current - .find((c) => (c.item as Tile).item.id === tileId)! - .get(); + const onTileDrag = ( + tileId: string, + { + tap, + initial: [initialX, initialY], + delta: [dx, dy], + last, + }: Parameters>[0] + ) => { + if (tap) { + setGrid((g) => cycleTileSize(tileId, g)); + } else { + const tileSpring = springRef.current + .find((c) => (c.item as Tile).item.id === tileId)! + .get(); - if (dragState.current === null) { - dragState.current = { tileId, x: tileSpring.x, y: tileSpring.y }; - } - dragState.current.x += dx; - dragState.current.y += dy; - - animateDraggedTile(last); - - if (last) dragState.current = null; + if (dragState.current === null) { + dragState.current = { + tileId, + tileX: tileSpring.x, + tileY: tileSpring.y, + cursorX: initialX - gridBounds.x, + cursorY: initialY - gridBounds.y + scrollOffset.current, + }; } - }, - { filterTaps: true, pointer: { buttons: [1] } } - ); + dragState.current.tileX += dx; + dragState.current.tileY += dy; + dragState.current.cursorX += dx; + dragState.current.cursorY += dy; + + animateDraggedTile(last); + + if (last) dragState.current = null; + } + }; + + const onTileDragRef = useRef(onTileDrag); + onTileDragRef.current = onTileDrag; const scrollOffset = useRef(0); - const bindGrid = useScroll(({ xy: [, y], delta: [, dy] }) => { - scrollOffset.current = y; + useScroll( + ({ xy: [, y], delta: [, dy] }) => { + scrollOffset.current = y; - if (dragState.current !== null) { - dragState.current.y += dy; - animateDraggedTile(false); - } - }); + if (dragState.current !== null) { + dragState.current.tileY += dy; + dragState.current.cursorY += dy; + animateDraggedTile(false); + } + }, + { target: gridRef2 } + ); const slots = useMemo(() => { const slots = new Array(items.length); @@ -639,7 +680,7 @@ export const NewVideoGrid: FC = ({ } return ( -
+
= ({ > {slots}
- {tileTransitions(({ shadow, width, height, ...style }, tile) => + {tileTransitions((style, tile) => children({ - ...bindTile(tile.item.id), + ...style, key: tile.item.id, - style: { - boxShadow: shadow.to( - (s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px` - ), - "--tileWidth": width.to((w) => `${w}px`), - "--tileHeight": height.to((h) => `${h}px`), - ...style, - }, - width: tile.width, - height: tile.height, + targetWidth: tile.width, + targetHeight: tile.height, item: tile.item, + onDragRef: onTileDragRef, }) )}
diff --git a/src/video-grid/VideoGrid.tsx b/src/video-grid/VideoGrid.tsx index fc6e44d8..2c6cb5b9 100644 --- a/src/video-grid/VideoGrid.tsx +++ b/src/video-grid/VideoGrid.tsx @@ -694,14 +694,17 @@ interface DragTileData { interface ChildrenProperties extends ReactDOMAttributes { key: Key; - style: { - scale: SpringValue; - opacity: SpringValue; - boxShadow: Interpolation; - }; - width: number; - height: number; + targetWidth: number; + targetHeight: number; item: TileDescriptor; + opacity: SpringValue; + scale: SpringValue; + shadow: SpringValue; + zIndex: SpringValue; + x: SpringValue; + y: SpringValue; + width: SpringValue; + height: SpringValue; [index: string]: unknown; } diff --git a/src/video-grid/VideoTile.tsx b/src/video-grid/VideoTile.tsx index 4e04199f..ca9ebbea 100644 --- a/src/video-grid/VideoTile.tsx +++ b/src/video-grid/VideoTile.tsx @@ -14,8 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { forwardRef } from "react"; -import { animated } from "@react-spring/web"; +import React, { ForwardedRef, forwardRef } from "react"; +import { animated, SpringValue } from "@react-spring/web"; import classNames from "classnames"; import { useTranslation } from "react-i18next"; @@ -44,9 +44,17 @@ interface Props { showOptions?: boolean; isLocal?: boolean; disableSpeakingIndicator?: boolean; + opacity: SpringValue; + scale: SpringValue; + shadow: SpringValue; + zIndex: SpringValue; + x: SpringValue; + y: SpringValue; + width: SpringValue; + height: SpringValue; } -export const VideoTile = forwardRef( +export const VideoTile = forwardRef( ( { name, @@ -68,6 +76,14 @@ export const VideoTile = forwardRef( isLocal, // TODO: disableSpeakingIndicator is not used atm. disableSpeakingIndicator, + opacity, + scale, + shadow, + zIndex, + x, + y, + width, + height, ...rest }, ref @@ -122,7 +138,19 @@ export const VideoTile = forwardRef( [styles.screenshare]: screenshare, [styles.maximised]: maximised, })} - ref={ref} + style={{ + opacity, + scale, + boxShadow: shadow.to( + (s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px` + ), + zIndex, + x, + y, + "--tileWidth": width.to((w) => `${w}px`), + "--tileHeight": height.to((h) => `${h}px`), + }} + ref={ref as ForwardedRef} {...rest} > {toolbarButtons.length > 0 && !maximised && ( diff --git a/src/video-grid/VideoTileContainer.tsx b/src/video-grid/VideoTileContainer.tsx index 1741ca85..28aec61d 100644 --- a/src/video-grid/VideoTileContainer.tsx +++ b/src/video-grid/VideoTileContainer.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes"; -import React from "react"; +import React, { FC, memo, RefObject } from "react"; import { useCallback } from "react"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -26,11 +26,13 @@ import { VideoTile } from "./VideoTile"; import { VideoTileSettingsModal } from "./VideoTileSettingsModal"; import { useModalTriggerState } from "../Modal"; import { TileDescriptor } from "./TileDescriptor"; +import { SpringValue } from "@react-spring/web"; +import { EventTypes, Handler, useDrag } from "@use-gesture/react"; interface Props { item: TileDescriptor; - width?: number; - height?: number; + targetWidth: number; + targetHeight: number; getAvatar: ( roomMember: RoomMember, width: number, @@ -42,86 +44,113 @@ interface Props { maximised: boolean; fullscreen: boolean; onFullscreen: (item: TileDescriptor) => void; + opacity: SpringValue; + scale: SpringValue; + shadow: SpringValue; + zIndex: SpringValue; + x: SpringValue; + y: SpringValue; + width: SpringValue; + height: SpringValue; + onDragRef: RefObject< + ( + tileId: string, + state: Parameters>[0] + ) => void + >; } -export function VideoTileContainer({ - item, - width, - height, - getAvatar, - audioContext, - audioDestination, - disableSpeakingIndicator, - maximised, - fullscreen, - onFullscreen, - ...rest -}: Props) { - const { - isLocal, - audioMuted, - videoMuted, - localVolume, - hasAudio, - speaking, - stream, - purpose, - } = useCallFeed(item.callFeed); - const { rawDisplayName } = useRoomMemberName(item.member); - const [tileRef, mediaRef] = useSpatialMediaStream( - stream ?? null, +export const VideoTileContainer: FC = memo( + ({ + item, + targetWidth, + targetHeight, + getAvatar, 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 { - modalState: videoTileSettingsModalState, - modalProps: videoTileSettingsModalProps, - } = useModalTriggerState(); - const onOptionsPress = () => { - videoTileSettingsModalState.open(); - }; + disableSpeakingIndicator, + maximised, + fullscreen, + onFullscreen, + onDragRef, + ...rest + }) => { + const { + isLocal, + audioMuted, + videoMuted, + localVolume, + hasAudio, + speaking, + stream, + purpose, + } = useCallFeed(item.callFeed); + const { rawDisplayName } = useRoomMemberName(item.member); - const onFullscreenCallback = useCallback(() => { - onFullscreen(item); - }, [onFullscreen, item]); + 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 + ); - // Firefox doesn't respect the disablePictureInPicture attribute - // https://bugzilla.mozilla.org/show_bug.cgi?id=1611831 + useDrag((state) => onDragRef.current!(item.id, state), { + target: tileRef, + filterTaps: true, + pointer: { buttons: [1] }, + }); - return ( - <> - - {videoTileSettingsModalState.isOpen && !maximised && item.callFeed && ( - { + 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 && ( + + )} + + ); + } +); diff --git a/src/video-grid/useMediaStream.ts b/src/video-grid/useMediaStream.ts index 259ede7b..9eb8e5aa 100644 --- a/src/video-grid/useMediaStream.ts +++ b/src/video-grid/useMediaStream.ts @@ -158,8 +158,8 @@ export const useSpatialMediaStream = ( audioDestination: AudioNode, localVolume: number, mute = false -): [RefObject, RefObject] => { - const tileRef = useRef(null); +): [RefObject, RefObject] => { + const tileRef = useRef(null); const [spatialAudio] = useSpatialAudio(); // This media stream is only used for the video - the audio goes via the audio