Merge remote-tracking branch 'upstream/main' into SimonBrandner/fix/audio

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2022-08-12 20:54:04 +02:00
commit e82ed2cbcb
No known key found for this signature in database
GPG key ID: D1D45825D60C24D2
11 changed files with 338 additions and 171 deletions

View file

@ -26,7 +26,7 @@ import menuStyles from "../Menu.module.css";
import { Menu } from "../Menu"; import { Menu } from "../Menu";
import { TooltipTrigger } from "../Tooltip"; import { TooltipTrigger } from "../Tooltip";
type Layout = "freedom" | "spotlight"; export type Layout = "freedom" | "spotlight";
interface Props { interface Props {
layout: Layout; layout: Layout;
setLayout: (layout: Layout) => void; setLayout: (layout: Layout) => void;

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { useCallback, useMemo, useRef } from "react"; import React, { useCallback, useMemo, useRef } from "react";
import { usePreventScroll } from "@react-aria/overlays"; import { usePreventScroll } from "@react-aria/overlays";
import { GroupCall, MatrixClient } from "matrix-js-sdk"; import { GroupCall, MatrixClient, RoomMember } from "matrix-js-sdk";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
import classNames from "classnames"; import classNames from "classnames";
@ -79,10 +79,10 @@ interface Props {
export interface Participant { export interface Participant {
id: string; id: string;
callFeed: CallFeed;
focused: boolean; focused: boolean;
isLocal: boolean;
presenter: boolean; presenter: boolean;
callFeed?: CallFeed;
isLocal?: boolean;
} }
export function InCallView({ export function InCallView({
@ -106,7 +106,7 @@ export function InCallView({
}: Props) { }: Props) {
usePreventScroll(); usePreventScroll();
const elementRef = useRef<HTMLDivElement>(); const elementRef = useRef<HTMLDivElement>();
const [layout, setLayout] = useVideoGridLayout(screenshareFeeds.length > 0); const { layout, setLayout } = useVideoGridLayout(screenshareFeeds.length > 0);
const { toggleFullscreen, fullscreenParticipant } = useFullscreen(elementRef); const { toggleFullscreen, fullscreenParticipant } = useFullscreen(elementRef);
const [spatialAudio] = useSpatialAudio(); const [spatialAudio] = useSpatialAudio();
@ -157,7 +157,8 @@ export function InCallView({
return participants; return participants;
}, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]); }, [userMediaFeeds, activeSpeaker, screenshareFeeds, layout]);
const renderAvatar = useCallback((roomMember, width, height) => { const renderAvatar = useCallback(
(roomMember: RoomMember, width: number, height: number) => {
const avatarUrl = roomMember.user?.avatarUrl; const avatarUrl = roomMember.user?.avatarUrl;
const size = Math.round(Math.min(width, height) / 2); const size = Math.round(Math.min(width, height) / 2);
@ -170,7 +171,9 @@ export function InCallView({
className={styles.avatar} className={styles.avatar}
/> />
); );
}, []); },
[]
);
const renderContent = useCallback((): JSX.Element => { const renderContent = useCallback((): JSX.Element => {
if (items.length === 0) { if (items.length === 0) {
@ -189,7 +192,7 @@ export function InCallView({
audioContext={audioContext} audioContext={audioContext}
audioDestination={audioDestination} audioDestination={audioDestination}
disableSpeakingIndicator={true} disableSpeakingIndicator={true}
isFullscreen={fullscreenParticipant} isFullscreen={!!fullscreenParticipant}
onFullscreen={toggleFullscreen} onFullscreen={toggleFullscreen}
/> />
); );
@ -202,11 +205,11 @@ export function InCallView({
key={item.id} key={item.id}
item={item} item={item}
getAvatar={renderAvatar} getAvatar={renderAvatar}
showName={items.length > 2 || item.focused} audioOutputDevice={audioOutput}
audioContext={audioContext} audioContext={audioContext}
audioDestination={audioDestination} audioDestination={audioDestination}
disableSpeakingIndicator={items.length < 3} disableSpeakingIndicator={items.length < 3}
isFullscreen={fullscreenParticipant} isFullscreen={!!fullscreenParticipant}
onFullscreen={toggleFullscreen} onFullscreen={toggleFullscreen}
{...rest} {...rest}
/> />
@ -221,6 +224,7 @@ export function InCallView({
layout, layout,
renderAvatar, renderAvatar,
toggleFullscreen, toggleFullscreen,
audioOutput,
]); ]);
const { const {

View file

@ -39,7 +39,7 @@ export function AudioForParticipant({
const sourceRef = useRef<MediaStreamAudioSourceNode>(); const sourceRef = useRef<MediaStreamAudioSourceNode>();
useEffect(() => { useEffect(() => {
if (!item.isLocal && stream.getAudioTracks().length > 0 && audioContext) { if (!item.isLocal && audioContext) {
if (!gainNodeRef.current) { if (!gainNodeRef.current) {
gainNodeRef.current = new GainNode(audioContext, { gainNodeRef.current = new GainNode(audioContext, {
gain: localVolume, gain: localVolume,

View file

@ -15,10 +15,12 @@ limitations under the License.
*/ */
import React, { useState } from "react"; import React, { useState } from "react";
import { useMemo } from "react";
import { VideoGrid, useVideoGridLayout } from "./VideoGrid"; import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
import { VideoTile } from "./VideoTile"; import { VideoTile } from "./VideoTile";
import { useMemo } from "react";
import { Button } from "../button"; import { Button } from "../button";
import { Participant } from "../room/InCallView";
export default { export default {
title: "VideoGrid", title: "VideoGrid",
@ -28,10 +30,10 @@ export default {
}; };
export const ParticipantsTest = () => { export const ParticipantsTest = () => {
const [layout, setLayout] = useVideoGridLayout(false); const { layout, setLayout } = useVideoGridLayout(false);
const [participantCount, setParticipantCount] = useState(1); const [participantCount, setParticipantCount] = useState(1);
const items = useMemo( const items: Participant[] = useMemo(
() => () =>
new Array(participantCount).fill(undefined).map((_, i) => ({ new Array(participantCount).fill(undefined).map((_, i) => ({
id: (i + 1).toString(), id: (i + 1).toString(),
@ -46,9 +48,7 @@ export const ParticipantsTest = () => {
<div style={{ display: "flex", width: "100vw", height: "32px" }}> <div style={{ display: "flex", width: "100vw", height: "32px" }}>
<Button <Button
onPress={() => onPress={() =>
setLayout((layout) => setLayout(layout === "freedom" ? "spotlight" : "freedom")
layout === "freedom" ? "spotlight" : "freedom"
)
} }
> >
Toggle Layout Toggle Layout
@ -76,7 +76,6 @@ export const ParticipantsTest = () => {
<VideoTile <VideoTile
key={item.id} key={item.id}
name={`User ${item.id}`} name={`User ${item.id}`}
showName={items.length > 2 || item.focused}
disableSpeakingIndicator={items.length < 3} disableSpeakingIndicator={items.length < 3}
{...rest} {...rest}
/> />

View file

@ -14,20 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useEffect, useRef, useState } from "react"; import React, { Key, useCallback, useEffect, useRef, useState } from "react";
import { useDrag, useGesture } from "@use-gesture/react"; import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
import { useSprings } from "@react-spring/web"; import { Interpolation, SpringValue, useSprings } from "@react-spring/web";
import useMeasure from "react-use-measure"; import useMeasure from "react-use-measure";
import { ResizeObserver } from "@juggle/resize-observer"; import { ResizeObserver } from "@juggle/resize-observer";
import styles from "./VideoGrid.module.css"; import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
export function useVideoGridLayout(hasScreenshareFeeds) { import styles from "./VideoGrid.module.css";
const layoutRef = useRef("freedom"); import { Layout } from "../room/GridLayoutMenu";
const revertLayoutRef = useRef("freedom"); import { Participant } from "../room/InCallView";
interface TilePosition {
x: number;
y: number;
width: number;
height: number;
zIndex: number;
}
interface Tile {
key: Key;
order: number;
item: Participant;
remove: boolean;
focused: boolean;
presenter: boolean;
}
type LayoutDirection = "vertical" | "horizontal";
export function useVideoGridLayout(hasScreenshareFeeds: boolean): {
layout: Layout;
setLayout: (layout: Layout) => void;
} {
const layoutRef = useRef<Layout>("freedom");
const revertLayoutRef = useRef<Layout>("freedom");
const prevHasScreenshareFeeds = useRef(hasScreenshareFeeds); const prevHasScreenshareFeeds = useRef(hasScreenshareFeeds);
const [, forceUpdate] = useState({}); const [, forceUpdate] = useState({});
const setLayout = useCallback((layout) => { const setLayout = useCallback((layout: Layout) => {
// Store the user's set layout to revert to after a screenshare is finished // Store the user's set layout to revert to after a screenshare is finished
revertLayoutRef.current = layout; revertLayoutRef.current = layout;
layoutRef.current = layout; layoutRef.current = layout;
@ -48,13 +74,13 @@ export function useVideoGridLayout(hasScreenshareFeeds) {
prevHasScreenshareFeeds.current = hasScreenshareFeeds; prevHasScreenshareFeeds.current = hasScreenshareFeeds;
return [layoutRef.current, setLayout]; return { layout: layoutRef.current, setLayout };
} }
const GAP = 8; const GAP = 8;
function useIsMounted() { function useIsMounted() {
const isMountedRef = useRef(false); const isMountedRef = useRef<boolean>(false);
useEffect(() => { useEffect(() => {
isMountedRef.current = true; isMountedRef.current = true;
@ -67,7 +93,7 @@ function useIsMounted() {
return isMountedRef; return isMountedRef;
} }
function isInside([x, y], targetTile) { function isInside([x, y]: number[], targetTile: TilePosition): boolean {
const left = targetTile.x; const left = targetTile.x;
const top = targetTile.y; const top = targetTile.y;
const bottom = targetTile.y + targetTile.height; const bottom = targetTile.y + targetTile.height;
@ -80,17 +106,18 @@ function isInside([x, y], targetTile) {
return true; return true;
} }
const getPipGap = (gridAspectRatio) => (gridAspectRatio < 1 ? 12 : 24); const getPipGap = (gridAspectRatio: number): number =>
gridAspectRatio < 1 ? 12 : 24;
function getTilePositions( function getTilePositions(
tileCount, tileCount: number,
presenterTileCount, presenterTileCount: number,
gridWidth, gridWidth: number,
gridHeight, gridHeight: number,
pipXRatio, pipXRatio: number,
pipYRatio, pipYRatio: number,
layout layout: Layout
) { ): TilePosition[] {
if (layout === "freedom") { if (layout === "freedom") {
if (tileCount === 2 && presenterTileCount === 0) { if (tileCount === 2 && presenterTileCount === 0) {
return getOneOnOneLayoutTilePositions( return getOneOnOneLayoutTilePositions(
@ -113,11 +140,11 @@ function getTilePositions(
} }
function getOneOnOneLayoutTilePositions( function getOneOnOneLayoutTilePositions(
gridWidth, gridWidth: number,
gridHeight, gridHeight: number,
pipXRatio, pipXRatio: number,
pipYRatio pipYRatio: number
) { ): TilePosition[] {
const [remotePosition] = getFreedomLayoutTilePositions( const [remotePosition] = getFreedomLayoutTilePositions(
1, 1,
0, 0,
@ -149,8 +176,12 @@ function getOneOnOneLayoutTilePositions(
]; ];
} }
function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) { function getSpotlightLayoutTilePositions(
const tilePositions = []; tileCount: number,
gridWidth: number,
gridHeight: number
): TilePosition[] {
const tilePositions: TilePosition[] = [];
const gridAspectRatio = gridWidth / gridHeight; const gridAspectRatio = gridWidth / gridHeight;
@ -215,11 +246,11 @@ function getSpotlightLayoutTilePositions(tileCount, gridWidth, gridHeight) {
} }
function getFreedomLayoutTilePositions( function getFreedomLayoutTilePositions(
tileCount, tileCount: number,
presenterTileCount, presenterTileCount: number,
gridWidth, gridWidth: number,
gridHeight gridHeight: number
) { ): TilePosition[] {
if (tileCount === 0) { if (tileCount === 0) {
return []; return [];
} }
@ -330,7 +361,14 @@ function getFreedomLayoutTilePositions(
return tilePositions; return tilePositions;
} }
function getSubGridBoundingBox(positions) { function getSubGridBoundingBox(positions: TilePosition[]): {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
} {
let left = 0; let left = 0;
let right = 0; let right = 0;
let top = 0; let top = 0;
@ -373,13 +411,18 @@ function getSubGridBoundingBox(positions) {
}; };
} }
function isMobileBreakpoint(gridWidth, gridHeight) { function isMobileBreakpoint(gridWidth: number, gridHeight: number): boolean {
const gridAspectRatio = gridWidth / gridHeight; const gridAspectRatio = gridWidth / gridHeight;
return gridAspectRatio < 1; return gridAspectRatio < 1;
} }
function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) { function getGridLayout(
let layoutDirection = "horizontal"; tileCount: number,
presenterTileCount: number,
gridWidth: number,
gridHeight: number
): { itemGridRatio: number; layoutDirection: LayoutDirection } {
let layoutDirection: LayoutDirection = "horizontal";
let itemGridRatio = 1; let itemGridRatio = 1;
if (presenterTileCount === 0) { if (presenterTileCount === 0) {
@ -397,7 +440,13 @@ function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) {
return { itemGridRatio, layoutDirection }; return { itemGridRatio, layoutDirection };
} }
function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) { function centerTiles(
positions: TilePosition[],
gridWidth: number,
gridHeight: number,
offsetLeft: number,
offsetTop: number
) {
const bounds = getSubGridBoundingBox(positions); const bounds = getSubGridBoundingBox(positions);
const leftOffset = Math.round((gridWidth - bounds.width) / 2) + offsetLeft; const leftOffset = Math.round((gridWidth - bounds.width) / 2) + offsetLeft;
@ -408,7 +457,11 @@ function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) {
return positions; return positions;
} }
function applyTileOffsets(positions, leftOffset, topOffset) { function applyTileOffsets(
positions: TilePosition[],
leftOffset: number,
topOffset: number
) {
for (const position of positions) { for (const position of positions) {
position.x += leftOffset; position.x += leftOffset;
position.y += topOffset; position.y += topOffset;
@ -417,12 +470,16 @@ function applyTileOffsets(positions, leftOffset, topOffset) {
return positions; return positions;
} }
function getSubGridLayout(tileCount, gridWidth, gridHeight) { function getSubGridLayout(
tileCount: number,
gridWidth: number,
gridHeight: number
): { columnCount: number; rowCount: number; tileAspectRatio: number } {
const gridAspectRatio = gridWidth / gridHeight; const gridAspectRatio = gridWidth / gridHeight;
let columnCount; let columnCount: number;
let rowCount; let rowCount: number;
let tileAspectRatio = 16 / 9; let tileAspectRatio: number = 16 / 9;
if (gridAspectRatio < 3 / 4) { if (gridAspectRatio < 3 / 4) {
// Phone // Phone
@ -528,26 +585,26 @@ function getSubGridLayout(tileCount, gridWidth, gridHeight) {
} }
function getSubGridPositions( function getSubGridPositions(
tileCount, tileCount: number,
columnCount, columnCount: number,
rowCount, rowCount: number,
tileAspectRatio, tileAspectRatio: number,
gridWidth, gridWidth: number,
gridHeight gridHeight: number
) { ) {
if (tileCount === 0) { if (tileCount === 0) {
return []; return [];
} }
const newTilePositions = []; const newTilePositions: TilePosition[] = [];
const boxWidth = Math.round( const boxWidth = Math.round(
(gridWidth - GAP * (columnCount + 1)) / columnCount (gridWidth - GAP * (columnCount + 1)) / columnCount
); );
const boxHeight = Math.round((gridHeight - GAP * (rowCount + 1)) / rowCount); const boxHeight = Math.round((gridHeight - GAP * (rowCount + 1)) / rowCount);
let tileWidth; let tileWidth: number;
let tileHeight; let tileHeight: number;
if (tileAspectRatio) { if (tileAspectRatio) {
const boxAspectRatio = boxWidth / boxHeight; const boxAspectRatio = boxWidth / boxHeight;
@ -568,7 +625,7 @@ function getSubGridPositions(
const verticalIndex = Math.floor(i / columnCount); const verticalIndex = Math.floor(i / columnCount);
const top = verticalIndex * GAP + verticalIndex * tileHeight; const top = verticalIndex * GAP + verticalIndex * tileHeight;
let rowItemCount; let rowItemCount: number;
if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) { if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) {
rowItemCount = tileCount % columnCount; rowItemCount = tileCount % columnCount;
@ -603,16 +660,16 @@ function getSubGridPositions(
return newTilePositions; return newTilePositions;
} }
function reorderTiles(tiles, layout) { function reorderTiles(tiles: Tile[], layout: Layout) {
if (layout === "freedom" && tiles.length === 2) { if (layout === "freedom" && tiles.length === 2) {
// 1:1 layout // 1:1 layout
tiles.forEach((tile) => (tile.order = tile.item.isLocal ? 0 : 1)); tiles.forEach((tile) => (tile.order = tile.item.isLocal ? 0 : 1));
} else { } else {
const focusedTiles = []; const focusedTiles: Tile[] = [];
const presenterTiles = []; const presenterTiles: Tile[] = [];
const otherTiles = []; const otherTiles: Tile[] = [];
const orderedTiles = new Array(tiles.length); const orderedTiles: Tile[] = new Array(tiles.length);
tiles.forEach((tile) => (orderedTiles[tile.order] = tile)); tiles.forEach((tile) => (orderedTiles[tile.order] = tile));
orderedTiles.forEach((tile) => orderedTiles.forEach((tile) =>
@ -630,27 +687,63 @@ function reorderTiles(tiles, layout) {
} }
} }
export function VideoGrid({ items, layout, disableAnimations, children }) { interface DragTileData {
offsetX: number;
offsetY: number;
key: Key;
x: number;
y: number;
}
interface ChildrenProperties extends ReactDOMAttributes {
key: Key;
style: {
scale: SpringValue<number>;
opacity: SpringValue<number>;
boxShadow: Interpolation<number, string>;
};
width: number;
height: number;
item: Participant;
[index: string]: unknown;
}
interface VideoGridProps {
items: Participant[];
layout: Layout;
disableAnimations?: boolean;
children: (props: ChildrenProperties) => React.ReactNode;
}
export function VideoGrid({
items,
layout,
disableAnimations,
children,
}: VideoGridProps) {
// Place the PiP in the bottom right corner by default // Place the PiP in the bottom right corner by default
const [pipXRatio, setPipXRatio] = useState(1); const [pipXRatio, setPipXRatio] = useState(1);
const [pipYRatio, setPipYRatio] = useState(1); const [pipYRatio, setPipYRatio] = useState(1);
const [{ tiles, tilePositions }, setTileState] = useState({ const [{ tiles, tilePositions }, setTileState] = useState<{
tiles: Tile[];
tilePositions: TilePosition[];
}>({
tiles: [], tiles: [],
tilePositions: [], tilePositions: [],
}); });
const [scrollPosition, setScrollPosition] = useState(0); const [scrollPosition, setScrollPosition] = useState<number>(0);
const draggingTileRef = useRef(null); const draggingTileRef = useRef<DragTileData>(null);
const lastTappedRef = useRef({}); const lastTappedRef = useRef<{ [index: Key]: number }>({});
const lastLayoutRef = useRef(layout); const lastLayoutRef = useRef<Layout>(layout);
const isMounted = useIsMounted(); const isMounted = useIsMounted();
const [gridRef, gridBounds] = useMeasure({ polyfill: ResizeObserver }); const [gridRef, gridBounds] = useMeasure({ polyfill: ResizeObserver });
useEffect(() => { useEffect(() => {
setTileState(({ tiles, ...rest }) => { setTileState(({ tiles, ...rest }) => {
const newTiles = []; const newTiles: Tile[] = [];
const removedTileKeys = new Set(); const removedTileKeys: Set<Key> = new Set();
for (const tile of tiles) { for (const tile of tiles) {
let item = items.find((item) => item.id === tile.key); let item = items.find((item) => item.id === tile.key);
@ -663,7 +756,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
removedTileKeys.add(tile.key); removedTileKeys.add(tile.key);
} }
let focused; let focused: boolean;
let presenter = false; let presenter = false;
if (layout === "spotlight") { if (layout === "spotlight") {
@ -694,7 +787,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
continue; continue;
} }
const newTile = { const newTile: Tile = {
key: item.id, key: item.id,
order: existingTile?.order ?? newTiles.length, order: existingTile?.order ?? newTiles.length,
item, item,
@ -721,7 +814,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
} }
setTileState(({ tiles, ...rest }) => { setTileState(({ tiles, ...rest }) => {
const newTiles = tiles const newTiles: Tile[] = tiles
.filter((tile) => !removedTileKeys.has(tile.key)) .filter((tile) => !removedTileKeys.has(tile.key))
.map((tile) => ({ ...tile })); // clone before reordering .map((tile) => ({ ...tile })); // clone before reordering
reorderTiles(newTiles, layout); reorderTiles(newTiles, layout);
@ -772,7 +865,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
}, [items, gridBounds, layout, isMounted, pipXRatio, pipYRatio]); }, [items, gridBounds, layout, isMounted, pipXRatio, pipYRatio]);
const animate = useCallback( const animate = useCallback(
(tiles) => (tileIndex) => { (tiles: Tile[]) => (tileIndex: number) => {
const tile = tiles[tileIndex]; const tile = tiles[tileIndex];
const tilePosition = tilePositions[tile.order]; const tilePosition = tilePositions[tile.order];
const draggingTile = draggingTileRef.current; const draggingTile = draggingTileRef.current;
@ -789,7 +882,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
opacity: 1, opacity: 1,
zIndex: 2, zIndex: 2,
shadow: 15, shadow: 15,
immediate: (key) => immediate: (key: string) =>
disableAnimations || disableAnimations ||
key === "zIndex" || key === "zIndex" ||
key === "x" || key === "x" ||
@ -831,11 +924,11 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
opacity: 0, opacity: 0,
}, },
reset: false, reset: false,
immediate: (key) => immediate: (key: string) =>
disableAnimations || key === "zIndex" || key === "shadow", disableAnimations || key === "zIndex" || key === "shadow",
// If we just stopped dragging a tile, give it time for its animation // If we just stopped dragging a tile, give it time for its animation
// to settle before pushing its z-index back down // to settle before pushing its z-index back down
delay: (key) => (key === "zIndex" ? 500 : 0), delay: (key: string) => (key === "zIndex" ? 500 : 0),
}; };
} }
}, },
@ -849,7 +942,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
]); ]);
const onTap = useCallback( const onTap = useCallback(
(tileKey) => { (tileKey: Key) => {
const lastTapped = lastTappedRef.current[tileKey]; const lastTapped = lastTappedRef.current[tileKey];
if (!lastTapped || Date.now() - lastTapped > 500) { if (!lastTapped || Date.now() - lastTapped > 500) {
@ -866,7 +959,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
setTileState(({ tiles, ...state }) => { setTileState(({ tiles, ...state }) => {
let presenterTileCount = 0; let presenterTileCount = 0;
const newTiles = tiles.map((tile) => { const newTiles = tiles.map((tile) => {
let newTile = { ...tile }; // clone before reordering const newTile = { ...tile }; // clone before reordering
if (tile.item === item) { if (tile.item === item) {
newTile.focused = !tile.focused; newTile.focused = !tile.focused;
@ -895,7 +988,7 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
}; };
}); });
}, },
[tiles, gridBounds, layout] [tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
); );
const bindTile = useDrag( const bindTile = useDrag(
@ -1008,12 +1101,18 @@ export function VideoGrid({ items, layout, disableAnimations, children }) {
); );
const onGridGesture = useCallback( const onGridGesture = useCallback(
(e, isWheel) => { (
e:
| Omit<FullGestureState<"wheel">, "event">
| Omit<FullGestureState<"drag">, "event">,
isWheel: boolean
) => {
if (layout !== "spotlight") { if (layout !== "spotlight") {
return; return;
} }
const isMobile = isMobileBreakpoint(gridBounds.width, gridBounds.height); const isMobile = isMobileBreakpoint(gridBounds.width, gridBounds.height);
let movement = e.delta[isMobile ? 0 : 1]; let movement = e.delta[isMobile ? 0 : 1];
if (isWheel) { if (isWheel) {

View file

@ -17,30 +17,49 @@ limitations under the License.
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import { animated } from "@react-spring/web"; import { animated } from "@react-spring/web";
import classNames from "classnames"; import classNames from "classnames";
import styles from "./VideoTile.module.css"; import styles from "./VideoTile.module.css";
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg"; import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg"; import { ReactComponent as VideoMutedIcon } from "../icons/VideoMuted.svg";
import { AudioButton, FullscreenButton } from "../button/Button"; import { AudioButton, FullscreenButton } from "../button/Button";
export const VideoTile = forwardRef( interface Props {
name: string;
speaking?: boolean;
audioMuted?: boolean;
videoMuted?: boolean;
screenshare?: boolean;
avatar?: JSX.Element;
mediaRef?: React.RefObject<MediaElement>;
onOptionsPress?: () => void;
localVolume?: number;
isFullscreen?: boolean;
onFullscreen?: () => void;
className?: string;
showOptions?: boolean;
isLocal?: boolean;
disableSpeakingIndicator?: boolean;
}
export const VideoTile = forwardRef<HTMLDivElement, Props>(
( (
{ {
className, name,
isLocal,
speaking, speaking,
audioMuted, audioMuted,
noVideo,
videoMuted, videoMuted,
screenshare, screenshare,
avatar, avatar,
name,
showName,
mediaRef, mediaRef,
onOptionsPress, onOptionsPress,
showOptions,
localVolume, localVolume,
isFullscreen, isFullscreen,
onFullscreen, onFullscreen,
className,
showOptions,
isLocal,
// TODO: disableSpeakingIndicator is not used atm.
disableSpeakingIndicator,
...rest ...rest
}, },
ref ref
@ -75,7 +94,7 @@ export const VideoTile = forwardRef(
)} )}
</div> </div>
)} )}
{(videoMuted || noVideo) && ( {videoMuted && (
<> <>
<div className={styles.videoMutedOverlay} /> <div className={styles.videoMutedOverlay} />
{avatar} {avatar}
@ -86,15 +105,12 @@ export const VideoTile = forwardRef(
<span>{`${name} is presenting`}</span> <span>{`${name} is presenting`}</span>
</div> </div>
) : ( ) : (
(showName || audioMuted || (videoMuted && !noVideo)) && (
<div className={classNames(styles.infoBubble, styles.memberName)}> <div className={classNames(styles.infoBubble, styles.memberName)}>
{audioMuted && !(videoMuted && !noVideo) && <MicMutedIcon />} {audioMuted && !videoMuted && <MicMutedIcon />}
{videoMuted && !noVideo && <VideoMutedIcon />} {videoMuted && <VideoMutedIcon />}
{showName && <span title={name}>{name}</span>} <span title={name}>{name}</span>
</div> </div>
)
)} )}
<video ref={mediaRef} playsInline disablePictureInPicture /> <video ref={mediaRef} playsInline disablePictureInPicture />
</animated.div> </animated.div>
); );

View file

@ -16,33 +16,51 @@ limitations under the License.
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes"; import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
import React from "react"; import React from "react";
import { useCallback } from "react";
import { RoomMember } from "matrix-js-sdk";
import { useCallFeed } from "./useCallFeed"; import { useCallFeed } from "./useCallFeed";
import { useSpatialMediaStream } from "./useMediaStream"; import { useSpatialMediaStream } from "./useMediaStream";
import { useRoomMemberName } from "./useRoomMemberName"; import { useRoomMemberName } from "./useRoomMemberName";
import { VideoTile } from "./VideoTile"; import { VideoTile } from "./VideoTile";
import { VideoTileSettingsModal } from "./VideoTileSettingsModal"; import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
import { useModalTriggerState } from "../Modal"; import { useModalTriggerState } from "../Modal";
import { useCallback } from "react"; import { Participant } from "../room/InCallView";
interface Props {
item: Participant;
width?: number;
height?: number;
getAvatar: (
roomMember: RoomMember,
width: number,
height: number
) => JSX.Element;
audioOutputDevice: string;
audioContext: AudioContext;
audioDestination: AudioNode;
disableSpeakingIndicator: boolean;
isFullscreen: boolean;
onFullscreen: (item: Participant) => void;
}
export function VideoTileContainer({ export function VideoTileContainer({
item, item,
width, width,
height, height,
getAvatar, getAvatar,
showName, audioOutputDevice,
audioContext, audioContext,
audioDestination, audioDestination,
disableSpeakingIndicator, disableSpeakingIndicator,
isFullscreen, isFullscreen,
onFullscreen, onFullscreen,
...rest ...rest
}) { }: Props) {
const { const {
isLocal, isLocal,
audioMuted, audioMuted,
videoMuted, videoMuted,
localVolume, localVolume,
noVideo,
speaking, speaking,
stream, stream,
purpose, purpose,
@ -77,11 +95,9 @@ export function VideoTileContainer({
isLocal={isLocal} isLocal={isLocal}
speaking={speaking && !disableSpeakingIndicator} speaking={speaking && !disableSpeakingIndicator}
audioMuted={audioMuted} audioMuted={audioMuted}
noVideo={noVideo}
videoMuted={videoMuted} videoMuted={videoMuted}
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare} screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
name={rawDisplayName} name={rawDisplayName}
showName={showName}
ref={tileRef} ref={tileRef}
mediaRef={mediaRef} mediaRef={mediaRef}
avatar={getAvatar && getAvatar(member, width, height)} avatar={getAvatar && getAvatar(member, width, height)}

View file

@ -23,6 +23,14 @@
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: transparent;
--slider-color: var(--quinary-content);
--slider-height: 4px;
--thumb-color: var(--accent);
--thumb-radius: 100%;
--thumb-size: 16px;
--thumb-margin-top: -6px;
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
} }
@ -31,51 +39,66 @@
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
height: 4px; background-color: var(--slider-color);
height: var(--slider-height);
} }
.localVolumeSlider[type="range"]::-ms-track { .localVolumeSlider[type="range"]::-ms-track {
-ms-appearance: none; -ms-appearance: none;
appearance: none; appearance: none;
height: 4px; background-color: var(--slider-color);
height: var(--slider-height);
} }
.localVolumeSlider[type="range"]::-webkit-slider-runnable-track { .localVolumeSlider[type="range"]::-webkit-slider-runnable-track {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 4px; background-color: var(--slider-color);
height: var(--slider-height);
} }
.localVolumeSlider[type="range"]::-moz-range-thumb { .localVolumeSlider[type="range"]::-moz-range-thumb {
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
height: 16px; height: var(--thumb-size);
width: 16px; width: var(--thumb-size);
margin-top: -6px; margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
border-radius: 100%; background: var(--thumb-color);
background: var(--accent);
} }
.localVolumeSlider[type="range"]::-ms-thumb { .localVolumeSlider[type="range"]::-ms-thumb {
-ms-appearance: none; -ms-appearance: none;
appearance: none; appearance: none;
height: 16px; height: var(--thumb-size);
width: 16px; width: var(--thumb-size);
margin-top: -6px; margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
border-radius: 100%; background: var(--thumb-color);
background: var(--accent);
} }
.localVolumeSlider[type="range"]::-webkit-slider-thumb { .localVolumeSlider[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
height: 16px; height: var(--thumb-size);
width: 16px; width: var(--thumb-size);
margin-top: -6px; margin-top: var(--thumb-margin-top);
border-radius: var(--thumb-radius);
border-radius: 100%; background: var(--thumb-color);
background: var(--accent); }
.localVolumeSlider[type="range"]::-moz-range-progress {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
}
.localVolumeSlider[type="range"]::-ms-fill-lower {
-moz-appearance: none;
appearance: none;
height: var(--slider-height);
background: var(--thumb-color);
} }

View file

@ -15,16 +15,25 @@ limitations under the License.
*/ */
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed"; import { CallFeed, CallFeedEvent } from "matrix-js-sdk/src/webrtc/callFeed";
import { RoomMember } from "matrix-js-sdk";
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
function getCallFeedState(callFeed) { interface CallFeedState {
member: RoomMember;
isLocal: boolean;
speaking: boolean;
videoMuted: boolean;
audioMuted: boolean;
localVolume: number;
stream: MediaStream;
purpose: SDPStreamMetadataPurpose;
}
function getCallFeedState(callFeed: CallFeed): CallFeedState {
return { return {
member: callFeed ? callFeed.getMember() : null, member: callFeed ? callFeed.getMember() : null,
isLocal: callFeed ? callFeed.isLocal() : false, isLocal: callFeed ? callFeed.isLocal() : false,
speaking: callFeed ? callFeed.isSpeaking() : false, speaking: callFeed ? callFeed.isSpeaking() : false,
noVideo: callFeed
? !callFeed.stream || callFeed.stream.getVideoTracks().length === 0
: true,
videoMuted: callFeed ? callFeed.isVideoMuted() : true, videoMuted: callFeed ? callFeed.isVideoMuted() : true,
audioMuted: callFeed ? callFeed.isAudioMuted() : true, audioMuted: callFeed ? callFeed.isAudioMuted() : true,
localVolume: callFeed ? callFeed.getLocalVolume() : 0, localVolume: callFeed ? callFeed.getLocalVolume() : 0,
@ -33,19 +42,21 @@ function getCallFeedState(callFeed) {
}; };
} }
export function useCallFeed(callFeed) { export function useCallFeed(callFeed: CallFeed): CallFeedState {
const [state, setState] = useState(() => getCallFeedState(callFeed)); const [state, setState] = useState<CallFeedState>(() =>
getCallFeedState(callFeed)
);
useEffect(() => { useEffect(() => {
function onSpeaking(speaking) { function onSpeaking(speaking: boolean) {
setState((prevState) => ({ ...prevState, speaking })); setState((prevState) => ({ ...prevState, speaking }));
} }
function onMuteStateChanged(audioMuted, videoMuted) { function onMuteStateChanged(audioMuted: boolean, videoMuted: boolean) {
setState((prevState) => ({ ...prevState, audioMuted, videoMuted })); setState((prevState) => ({ ...prevState, audioMuted, videoMuted }));
} }
function onLocalVolumeChanged(localVolume) { function onLocalVolumeChanged(localVolume: number) {
setState((prevState) => ({ ...prevState, localVolume })); setState((prevState) => ({ ...prevState, localVolume }));
} }

View file

@ -181,8 +181,8 @@ export const useSpatialMediaStream = (
audioDestination: AudioNode, audioDestination: AudioNode,
mute = false, mute = false,
localVolume?: number localVolume?: number
): [RefObject<Element>, RefObject<MediaElement>] => { ): [RefObject<HTMLDivElement>, RefObject<MediaElement>] => {
const tileRef = useRef<Element>(); const tileRef = useRef<HTMLDivElement>();
const [spatialAudio] = useSpatialAudio(); const [spatialAudio] = useSpatialAudio();
// We always handle audio separately form the video element // We always handle audio separately form the video element
const mediaRef = useMediaStream(stream, undefined, true, undefined); const mediaRef = useMediaStream(stream, undefined, true, undefined);
@ -192,13 +192,7 @@ export const useSpatialMediaStream = (
const sourceRef = useRef<MediaStreamAudioSourceNode>(); const sourceRef = useRef<MediaStreamAudioSourceNode>();
useEffect(() => { useEffect(() => {
if ( if (spatialAudio && audioContext && tileRef.current && !mute) {
spatialAudio &&
audioContext &&
tileRef.current &&
!mute &&
stream.getAudioTracks().length > 0
) {
if (!pannerNodeRef.current) { if (!pannerNodeRef.current) {
pannerNodeRef.current = new PannerNode(audioContext, { pannerNodeRef.current = new PannerNode(audioContext, {
panningModel: "HRTF", panningModel: "HRTF",

View file

@ -14,10 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { RoomMember, RoomMemberEvent } from "matrix-js-sdk";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
export function useRoomMemberName(member) { interface RoomMemberName {
const [state, setState] = useState({ name: string;
rawDisplayName: string;
}
export function useRoomMemberName(member: RoomMember): RoomMemberName {
const [state, setState] = useState<RoomMemberName>({
name: member.name, name: member.name,
rawDisplayName: member.rawDisplayName, rawDisplayName: member.rawDisplayName,
}); });
@ -29,10 +34,10 @@ export function useRoomMemberName(member) {
updateName(); updateName();
member.on("RoomMember.name", updateName); member.on(RoomMemberEvent.Name, updateName);
return () => { return () => {
member.removeListener("RoomMember.name", updateName); member.removeListener(RoomMemberEvent.Name, updateName);
}; };
}, [member]); }, [member]);