mirror of
https://github.com/vector-im/element-call.git
synced 2024-11-21 00:28:08 +08:00
Update to use matrix-react-sdk
This commit is contained in:
parent
c4a626b530
commit
5e4736eba5
26
README.md
26
README.md
@ -6,32 +6,8 @@ Testbed for full mesh video chat.
|
||||
|
||||
You must first run a local Synapse server on port 8008
|
||||
|
||||
Then install the dependencies:
|
||||
|
||||
```
|
||||
cd matrix-video-chat
|
||||
npm install
|
||||
```
|
||||
|
||||
Locally checkout the [robertlong/full-mesh-voip](https://github.com/matrix-org/matrix-js-sdk/tree/robertlong/full-mesh-voip) branch of the matrix-js-sdk.
|
||||
|
||||
```
|
||||
cd matrix-js-sdk
|
||||
git checkout robertlong/full-mesh-voip
|
||||
yarn
|
||||
yarn build
|
||||
npm link
|
||||
```
|
||||
|
||||
Link the matrix-js-sdk into the matrix-video-chat project:
|
||||
|
||||
```
|
||||
cd matrix-video-chat
|
||||
npm link matrix-js-sdk
|
||||
```
|
||||
|
||||
Finally run the development server
|
||||
|
||||
```
|
||||
npm run dev
|
||||
yarn dev
|
||||
```
|
3064
package-lock.json
generated
3064
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
11
package.json
11
package.json
@ -11,19 +11,18 @@
|
||||
"color-hash": "^2.0.1",
|
||||
"events": "^3.3.0",
|
||||
"lodash-move": "^1.1.1",
|
||||
"matrix-js-sdk": "file:../matrix-js-sdk",
|
||||
"matrix-react-sdk": "file:../matrix-react-sdk",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#robertlong/group-call",
|
||||
"matrix-react-sdk": "github:matrix-org/matrix-react-sdk#robertlong/group-call",
|
||||
"re-resizable": "^6.9.0",
|
||||
"react": "^17.0.0",
|
||||
"react-dom": "^17.0.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use-gesture": "^9.1.3",
|
||||
"react-use-measure": "^2.0.4",
|
||||
"sass": "^1.42.1",
|
||||
"vite-plugin-svgr": "^0.4.0"
|
||||
"react-use-measure": "^2.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react-refresh": "^1.3.1",
|
||||
"vite-plugin-svgr": "^0.4.0",
|
||||
"sass": "^1.42.1",
|
||||
"vite": "^2.4.2"
|
||||
}
|
||||
}
|
||||
|
@ -25,7 +25,6 @@ import {
|
||||
import { useClient } from "./ConferenceCallManagerHooks";
|
||||
import { Home } from "./Home";
|
||||
import { Room } from "./Room";
|
||||
import { GridDemo } from "./GridDemo";
|
||||
import { RegisterPage } from "./RegisterPage";
|
||||
import { LoginPage } from "./LoginPage";
|
||||
import { Center } from "./Layout";
|
||||
@ -70,9 +69,6 @@ export default function App() {
|
||||
<GuestAuthPage onRegisterGuest={registerGuest} />
|
||||
)}
|
||||
</Route>
|
||||
<Route exact path="/grid">
|
||||
<GridDemo />
|
||||
</Route>
|
||||
</Switch>
|
||||
)}
|
||||
</>
|
||||
|
@ -14,25 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import matrix from "matrix-js-sdk/src/index";
|
||||
import { ConferenceCallDebugger } from "./ConferenceCallDebugger";
|
||||
|
||||
// https://stackoverflow.com/a/9039885
|
||||
function isIOS() {
|
||||
return (
|
||||
[
|
||||
"iPad Simulator",
|
||||
"iPhone Simulator",
|
||||
"iPod Simulator",
|
||||
"iPad",
|
||||
"iPhone",
|
||||
"iPod",
|
||||
].includes(navigator.platform) ||
|
||||
// iPad on iOS 13 detection
|
||||
(navigator.userAgent.includes("Mac") && "ontouchend" in document)
|
||||
);
|
||||
}
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import matrix from "matrix-js-sdk/src/browser-index";
|
||||
|
||||
function waitForSync(client) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -245,210 +228,6 @@ export function useClient(homeserverUrl) {
|
||||
};
|
||||
}
|
||||
|
||||
function usePageUnload(callback) {
|
||||
useEffect(() => {
|
||||
let pageVisibilityTimeout;
|
||||
|
||||
function onBeforeUnload(event) {
|
||||
if (event.type === "visibilitychange") {
|
||||
if (document.visibilityState === "visible") {
|
||||
clearTimeout(pageVisibilityTimeout);
|
||||
} else {
|
||||
// Wait 5 seconds before closing the page to avoid accidentally leaving
|
||||
// TODO: Make this configurable?
|
||||
pageVisibilityTimeout = setTimeout(() => {
|
||||
callback();
|
||||
}, 5000);
|
||||
}
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
// iOS doesn't fire beforeunload event, so leave the call when you hide the page.
|
||||
if (isIOS()) {
|
||||
window.addEventListener("pagehide", onBeforeUnload);
|
||||
document.addEventListener("visibilitychange", onBeforeUnload);
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pagehide", onBeforeUnload);
|
||||
document.removeEventListener("visibilitychange", onBeforeUnload);
|
||||
window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
clearTimeout(pageVisibilityTimeout);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
function getParticipants(groupCall) {
|
||||
return [...groupCall.participants];
|
||||
}
|
||||
|
||||
export function useGroupCall(client, roomId, debug = false) {
|
||||
const groupCallRef = useRef(null);
|
||||
|
||||
const [
|
||||
{
|
||||
loading,
|
||||
entered,
|
||||
entering,
|
||||
room,
|
||||
participants,
|
||||
error,
|
||||
microphoneMuted,
|
||||
localVideoMuted,
|
||||
callDebugger,
|
||||
},
|
||||
setState,
|
||||
] = useState({
|
||||
loading: true,
|
||||
entered: false,
|
||||
entering: false,
|
||||
room: null,
|
||||
participants: [],
|
||||
error: null,
|
||||
microphoneMuted: false,
|
||||
localVideoMuted: false,
|
||||
callDebugger: null,
|
||||
});
|
||||
|
||||
const updateState = (state) =>
|
||||
setState((prevState) => ({ ...prevState, ...state }));
|
||||
|
||||
useEffect(() => {
|
||||
function onParticipantsChanged(...args) {
|
||||
updateState({ participants: getParticipants(groupCallRef.current) });
|
||||
}
|
||||
|
||||
function onLocalMuteStateChanged(microphoneMuted, localVideoMuted) {
|
||||
updateState({
|
||||
microphoneMuted,
|
||||
localVideoMuted,
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const room = await fetchRoom(client, roomId, true);
|
||||
|
||||
const groupCall = client.createGroupCall(roomId, "video");
|
||||
groupCallRef.current = groupCall;
|
||||
groupCall.on("active_speaker_changed", onParticipantsChanged);
|
||||
groupCall.on("participants_changed", onParticipantsChanged);
|
||||
groupCall.on("speaking", onParticipantsChanged);
|
||||
groupCall.on("mute_state_changed", onParticipantsChanged);
|
||||
groupCall.on("call_replaced", onParticipantsChanged);
|
||||
groupCall.on("call_feeds_changed", onParticipantsChanged);
|
||||
groupCall.on("local_mute_state_changed", onLocalMuteStateChanged);
|
||||
|
||||
updateState({
|
||||
room,
|
||||
loading: false,
|
||||
callDebugger: debug
|
||||
? new ConferenceCallDebugger(client, groupCall)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
init().catch((error) => {
|
||||
if (groupCallRef.current) {
|
||||
const groupCall = groupCallRef.current;
|
||||
groupCall.removeListener(
|
||||
"active_speaker_changed",
|
||||
onParticipantsChanged
|
||||
);
|
||||
groupCall.removeListener("participants_changed", onParticipantsChanged);
|
||||
groupCall.removeListener("speaking", onParticipantsChanged);
|
||||
groupCall.removeListener("mute_state_changed", onParticipantsChanged);
|
||||
groupCall.removeListener("call_replaced", onParticipantsChanged);
|
||||
groupCall.removeListener("call_feeds_changed", onParticipantsChanged);
|
||||
groupCall.removeListener(
|
||||
"local_mute_state_changed",
|
||||
onLocalMuteStateChanged
|
||||
);
|
||||
groupCall.leave();
|
||||
}
|
||||
|
||||
updateState({ error, loading: false });
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (groupCallRef.current) {
|
||||
groupCallRef.current.leave();
|
||||
}
|
||||
};
|
||||
}, [client, roomId]);
|
||||
|
||||
usePageUnload(() => {
|
||||
if (groupCallRef.current) {
|
||||
groupCallRef.current.leave();
|
||||
}
|
||||
});
|
||||
|
||||
const initLocalParticipant = useCallback(
|
||||
() => groupCallRef.current.initLocalParticipant(),
|
||||
[]
|
||||
);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
updateState({ entering: true });
|
||||
|
||||
groupCallRef.current
|
||||
.enter()
|
||||
.then(() => {
|
||||
updateState({
|
||||
entered: true,
|
||||
entering: false,
|
||||
participants: getParticipants(groupCallRef.current),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
updateState({ error, entering: false });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const leave = useCallback(() => {
|
||||
groupCallRef.current.leave();
|
||||
updateState({
|
||||
entered: false,
|
||||
participants: [],
|
||||
microphoneMuted: false,
|
||||
localVideoMuted: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleLocalVideoMuted = useCallback(() => {
|
||||
groupCallRef.current.setLocalVideoMuted(
|
||||
!groupCallRef.current.isLocalVideoMuted()
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleMicrophoneMuted = useCallback(() => {
|
||||
groupCallRef.current.setMicrophoneMuted(
|
||||
!groupCallRef.current.isMicrophoneMuted()
|
||||
);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
loading,
|
||||
entered,
|
||||
entering,
|
||||
roomName: room ? room.name : null,
|
||||
participants,
|
||||
groupCall: groupCallRef.current,
|
||||
callDebugger: callDebugger,
|
||||
microphoneMuted,
|
||||
localVideoMuted,
|
||||
error,
|
||||
initLocalParticipant,
|
||||
enter,
|
||||
leave,
|
||||
toggleLocalVideoMuted,
|
||||
toggleMicrophoneMuted,
|
||||
};
|
||||
}
|
||||
|
||||
const tsCache = {};
|
||||
|
||||
function getLastTs(client, r) {
|
||||
|
@ -1,49 +0,0 @@
|
||||
import React, { useCallback, useRef, useState } from "react";
|
||||
import styles from "./GridDemo.module.css";
|
||||
import { VideoGrid } from "./VideoGrid";
|
||||
|
||||
export function GridDemo() {
|
||||
const participantKey = useRef(0);
|
||||
const [stream, setStream] = useState();
|
||||
const [participants, setParticipants] = useState([]);
|
||||
|
||||
const startWebcam = useCallback(async () => {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
setStream(stream);
|
||||
setParticipants([{ stream, userId: participantKey.current++ }]);
|
||||
}, []);
|
||||
|
||||
const addParticipant = useCallback(() => {
|
||||
setParticipants((participants) => [
|
||||
...participants,
|
||||
{ stream: stream.clone(), userId: participantKey.current++ },
|
||||
]);
|
||||
}, [stream]);
|
||||
|
||||
const removeParticipant = useCallback((key) => {
|
||||
setParticipants((participants) =>
|
||||
participants.filter((participant) => participant.userId !== key)
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.gridDemo}>
|
||||
<div className={styles.buttons}>
|
||||
{!stream && <button onClick={startWebcam}>Start Webcam</button>}
|
||||
{stream && participants.length < 12 && (
|
||||
<button onClick={addParticipant}>Add Tile</button>
|
||||
)}
|
||||
{stream && participants.length > 0 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
removeParticipant(participants[participants.length - 1].userId)
|
||||
}
|
||||
>
|
||||
Remove Tile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<VideoGrid participants={participants} />
|
||||
</div>
|
||||
);
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
.gridDemo {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
@ -30,7 +30,9 @@ import {
|
||||
GroupCallState,
|
||||
GroupCallType,
|
||||
} from "matrix-js-sdk/src/webrtc/groupCall";
|
||||
import VideoGrid from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
|
||||
import VideoGrid, {
|
||||
useVideoGridLayout,
|
||||
} from "matrix-react-sdk/src/components/views/voip/GroupCallView/VideoGrid";
|
||||
import "matrix-react-sdk/res/css/views/voip/GroupCallView/_VideoGrid.scss";
|
||||
import { useGroupCall } from "matrix-react-sdk/src/hooks/useGroupCall";
|
||||
import { useCallFeed } from "matrix-react-sdk/src/hooks/useCallFeed";
|
||||
|
@ -1,763 +0,0 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useDrag } from "react-use-gesture";
|
||||
import { useSprings, animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import styles from "./VideoGrid.module.css";
|
||||
import useMeasure from "react-use-measure";
|
||||
import moveArrItem from "lodash-move";
|
||||
import { ReactComponent as MicIcon } from "./icons/Mic.svg";
|
||||
import { ReactComponent as MuteMicIcon } from "./icons/MuteMic.svg";
|
||||
import { ReactComponent as DisableVideoIcon } from "./icons/DisableVideo.svg";
|
||||
|
||||
function useIsMounted() {
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return isMountedRef;
|
||||
}
|
||||
|
||||
function isInside([x, y], targetTile) {
|
||||
const left = targetTile.x;
|
||||
const top = targetTile.y;
|
||||
const bottom = targetTile.y + targetTile.height;
|
||||
const right = targetTile.x + targetTile.width;
|
||||
|
||||
if (x < left || x > right || y < top || y > bottom) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTilePositions(
|
||||
tileCount,
|
||||
presenterTileCount,
|
||||
gridWidth,
|
||||
gridHeight
|
||||
) {
|
||||
if (tileCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (tileCount > 12) {
|
||||
console.warn("Over 12 tiles is not currently supported");
|
||||
}
|
||||
|
||||
const gap = 8;
|
||||
|
||||
const { layoutDirection, participantGridRatio } = getGridLayout(
|
||||
tileCount,
|
||||
presenterTileCount,
|
||||
gridWidth,
|
||||
gridHeight
|
||||
);
|
||||
|
||||
let participantGridWidth, participantGridHeight;
|
||||
|
||||
if (layoutDirection === "vertical") {
|
||||
participantGridWidth = gridWidth;
|
||||
participantGridHeight = Math.round(gridHeight * participantGridRatio);
|
||||
} else {
|
||||
participantGridWidth = Math.round(gridWidth * participantGridRatio);
|
||||
participantGridHeight = gridHeight;
|
||||
}
|
||||
|
||||
const participantTileCount = tileCount - presenterTileCount;
|
||||
|
||||
const participantGridPositions = getSubGridPositions(
|
||||
participantTileCount,
|
||||
participantGridWidth,
|
||||
participantGridHeight,
|
||||
gap
|
||||
);
|
||||
const participantGridBounds = getSubGridBoundingBox(participantGridPositions);
|
||||
|
||||
let presenterGridWidth, presenterGridHeight;
|
||||
|
||||
if (presenterTileCount === 0) {
|
||||
presenterGridWidth = 0;
|
||||
presenterGridHeight = 0;
|
||||
} else if (layoutDirection === "vertical") {
|
||||
presenterGridWidth = gridWidth;
|
||||
presenterGridHeight =
|
||||
gridHeight -
|
||||
(participantGridBounds.height + (participantTileCount ? gap * 2 : 0));
|
||||
} else {
|
||||
presenterGridWidth =
|
||||
gridWidth -
|
||||
(participantGridBounds.width + (participantTileCount ? gap * 2 : 0));
|
||||
presenterGridHeight = gridHeight;
|
||||
}
|
||||
|
||||
const presenterGridPositions = getSubGridPositions(
|
||||
presenterTileCount,
|
||||
presenterGridWidth,
|
||||
presenterGridHeight,
|
||||
gap
|
||||
);
|
||||
|
||||
const tilePositions = [
|
||||
...presenterGridPositions,
|
||||
...participantGridPositions,
|
||||
];
|
||||
|
||||
centerTiles(
|
||||
presenterGridPositions,
|
||||
presenterGridWidth,
|
||||
presenterGridHeight,
|
||||
0,
|
||||
0
|
||||
);
|
||||
|
||||
if (layoutDirection === "vertical") {
|
||||
centerTiles(
|
||||
participantGridPositions,
|
||||
gridWidth,
|
||||
gridHeight - presenterGridHeight,
|
||||
0,
|
||||
presenterGridHeight
|
||||
);
|
||||
} else {
|
||||
centerTiles(
|
||||
participantGridPositions,
|
||||
gridWidth - presenterGridWidth,
|
||||
gridHeight,
|
||||
presenterGridWidth,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
return tilePositions;
|
||||
}
|
||||
|
||||
function getSubGridBoundingBox(positions) {
|
||||
let left = 0,
|
||||
right = 0,
|
||||
top = 0,
|
||||
bottom = 0;
|
||||
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const { x, y, width, height } = positions[i];
|
||||
|
||||
if (i === 0) {
|
||||
left = x;
|
||||
right = x + width;
|
||||
top = y;
|
||||
bottom = y + height;
|
||||
} else {
|
||||
if (x < left) {
|
||||
left = x;
|
||||
}
|
||||
|
||||
if (y < top) {
|
||||
top = y;
|
||||
}
|
||||
|
||||
if (x + width > right) {
|
||||
right = x + width;
|
||||
}
|
||||
|
||||
if (y + height > bottom) {
|
||||
bottom = y + height;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
left,
|
||||
right,
|
||||
top,
|
||||
bottom,
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
};
|
||||
}
|
||||
|
||||
function getGridLayout(tileCount, presenterTileCount, gridWidth, gridHeight) {
|
||||
let layoutDirection = "horizontal";
|
||||
let participantGridRatio = 1;
|
||||
|
||||
if (presenterTileCount === 0) {
|
||||
return { participantGridRatio, layoutDirection };
|
||||
}
|
||||
|
||||
const gridAspectRatio = gridWidth / gridHeight;
|
||||
|
||||
if (gridAspectRatio < 1) {
|
||||
layoutDirection = "vertical";
|
||||
participantGridRatio = 1 / 3;
|
||||
} else {
|
||||
layoutDirection = "horizontal";
|
||||
participantGridRatio = 1 / 3;
|
||||
}
|
||||
|
||||
return { participantGridRatio, layoutDirection };
|
||||
}
|
||||
|
||||
function centerTiles(positions, gridWidth, gridHeight, offsetLeft, offsetTop) {
|
||||
const bounds = getSubGridBoundingBox(positions);
|
||||
|
||||
const leftOffset = Math.round((gridWidth - bounds.width) / 2) + offsetLeft;
|
||||
const topOffset = Math.round((gridHeight - bounds.height) / 2) + offsetTop;
|
||||
|
||||
applyTileOffsets(positions, leftOffset, topOffset);
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function applyTileOffsets(positions, leftOffset, topOffset) {
|
||||
for (const position of positions) {
|
||||
position.x += leftOffset;
|
||||
position.y += topOffset;
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
function getSubGridLayout(tileCount, gridWidth, gridHeight) {
|
||||
const gridAspectRatio = gridWidth / gridHeight;
|
||||
|
||||
let columnCount, rowCount;
|
||||
let tileAspectRatio = 16 / 9;
|
||||
|
||||
if (gridAspectRatio < 3 / 4) {
|
||||
// Phone
|
||||
if (tileCount === 1) {
|
||||
columnCount = 1;
|
||||
rowCount = 1;
|
||||
tileAspectRatio = 0;
|
||||
} else if (tileCount <= 4) {
|
||||
columnCount = 1;
|
||||
rowCount = tileCount;
|
||||
} else if (tileCount <= 12) {
|
||||
columnCount = 2;
|
||||
rowCount = Math.ceil(tileCount / columnCount);
|
||||
tileAspectRatio = 0;
|
||||
} else {
|
||||
// Unsupported
|
||||
columnCount = 3;
|
||||
rowCount = Math.ceil(tileCount / columnCount);
|
||||
tileAspectRatio = 1;
|
||||
}
|
||||
} else if (gridAspectRatio < 1) {
|
||||
// Tablet
|
||||
if (tileCount === 1) {
|
||||
columnCount = 1;
|
||||
rowCount = 1;
|
||||
tileAspectRatio = 0;
|
||||
} else if (tileCount <= 4) {
|
||||
columnCount = 1;
|
||||
rowCount = tileCount;
|
||||
} else if (tileCount <= 12) {
|
||||
columnCount = 2;
|
||||
rowCount = Math.ceil(tileCount / columnCount);
|
||||
} else {
|
||||
// Unsupported
|
||||
columnCount = 3;
|
||||
rowCount = Math.ceil(tileCount / columnCount);
|
||||
tileAspectRatio = 1;
|
||||
}
|
||||
} else if (gridAspectRatio < 17 / 9) {
|
||||
// Computer
|
||||
if (tileCount === 1) {
|
||||
columnCount = 1;
|
||||
rowCount = 1;
|
||||
} else if (tileCount === 2) {
|
||||
columnCount = 2;
|
||||
rowCount = 1;
|
||||
} else if (tileCount <= 4) {
|
||||
columnCount = 2;
|
||||
rowCount = 2;
|
||||
} else if (tileCount <= 6) {
|
||||
columnCount = 3;
|
||||
rowCount = 2;
|
||||
} else if (tileCount <= 8) {
|
||||
columnCount = 4;
|
||||
rowCount = 2;
|
||||
tileAspectRatio = 1;
|
||||
} else if (tileCount <= 12) {
|
||||
columnCount = 4;
|
||||
rowCount = 3;
|
||||
tileAspectRatio = 1;
|
||||
} else {
|
||||
// Unsupported
|
||||
columnCount = 4;
|
||||
rowCount = 4;
|
||||
}
|
||||
} else if (gridAspectRatio <= 32 / 9) {
|
||||
// Ultrawide
|
||||
if (tileCount === 1) {
|
||||
columnCount = 1;
|
||||
rowCount = 1;
|
||||
} else if (tileCount === 2) {
|
||||
columnCount = 2;
|
||||
rowCount = 1;
|
||||
} else if (tileCount <= 4) {
|
||||
columnCount = 2;
|
||||
rowCount = 2;
|
||||
} else if (tileCount <= 6) {
|
||||
columnCount = 3;
|
||||
rowCount = 2;
|
||||
} else if (tileCount <= 8) {
|
||||
columnCount = 4;
|
||||
rowCount = 2;
|
||||
} else if (tileCount <= 12) {
|
||||
columnCount = 4;
|
||||
rowCount = 3;
|
||||
} else {
|
||||
// Unsupported
|
||||
columnCount = 4;
|
||||
rowCount = 4;
|
||||
}
|
||||
} else {
|
||||
// Super Ultrawide
|
||||
if (tileCount <= 6) {
|
||||
columnCount = tileCount;
|
||||
rowCount = 1;
|
||||
} else {
|
||||
columnCount = Math.ceil(tileCount / 2);
|
||||
rowCount = 2;
|
||||
}
|
||||
}
|
||||
|
||||
return { columnCount, rowCount, tileAspectRatio };
|
||||
}
|
||||
|
||||
function getSubGridPositions(tileCount, gridWidth, gridHeight, gap) {
|
||||
if (tileCount === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { columnCount, rowCount, tileAspectRatio } = getSubGridLayout(
|
||||
tileCount,
|
||||
gridWidth,
|
||||
gridHeight
|
||||
);
|
||||
|
||||
const newTilePositions = [];
|
||||
|
||||
const boxWidth = Math.round(
|
||||
(gridWidth - gap * (columnCount + 1)) / columnCount
|
||||
);
|
||||
const boxHeight = Math.round((gridHeight - gap * (rowCount + 1)) / rowCount);
|
||||
|
||||
let tileWidth, tileHeight;
|
||||
|
||||
if (tileAspectRatio) {
|
||||
const boxAspectRatio = boxWidth / boxHeight;
|
||||
|
||||
if (boxAspectRatio > tileAspectRatio) {
|
||||
tileWidth = boxHeight * tileAspectRatio;
|
||||
tileHeight = boxHeight;
|
||||
} else {
|
||||
tileWidth = boxWidth;
|
||||
tileHeight = boxWidth / tileAspectRatio;
|
||||
}
|
||||
} else {
|
||||
tileWidth = boxWidth;
|
||||
tileHeight = boxHeight;
|
||||
}
|
||||
|
||||
for (let i = 0; i < tileCount; i++) {
|
||||
const verticalIndex = Math.floor(i / columnCount);
|
||||
const top = verticalIndex * gap + verticalIndex * tileHeight;
|
||||
|
||||
let rowItemCount;
|
||||
|
||||
if (verticalIndex + 1 === rowCount && tileCount % columnCount !== 0) {
|
||||
rowItemCount = tileCount % columnCount;
|
||||
} else {
|
||||
rowItemCount = columnCount;
|
||||
}
|
||||
|
||||
const horizontalIndex = i % columnCount;
|
||||
|
||||
let centeringPadding = 0;
|
||||
|
||||
if (rowItemCount < columnCount) {
|
||||
const subgridWidth = tileWidth * columnCount + (gap * columnCount - 1);
|
||||
centeringPadding = Math.round(
|
||||
(subgridWidth - (tileWidth * rowItemCount + (gap * rowItemCount - 1))) /
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
const left =
|
||||
centeringPadding + gap * horizontalIndex + tileWidth * horizontalIndex;
|
||||
|
||||
newTilePositions.push({
|
||||
width: tileWidth,
|
||||
height: tileHeight,
|
||||
x: left,
|
||||
y: top,
|
||||
});
|
||||
}
|
||||
|
||||
return newTilePositions;
|
||||
}
|
||||
|
||||
export function VideoGrid({ participants, layout }) {
|
||||
const [{ tiles, tilePositions }, setTileState] = useState({
|
||||
tiles: [],
|
||||
tilePositions: [],
|
||||
});
|
||||
const draggingTileRef = useRef(null);
|
||||
const lastTappedRef = useRef({});
|
||||
const lastLayoutRef = useRef(layout);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const [gridRef, gridBounds] = useMeasure();
|
||||
|
||||
useEffect(() => {
|
||||
setTileState(({ tiles }) => {
|
||||
const newTiles = [];
|
||||
const removedTileKeys = [];
|
||||
|
||||
for (const tile of tiles) {
|
||||
let participant = participants.find(
|
||||
(participant) => participant.member.userId === tile.key
|
||||
);
|
||||
|
||||
let remove = false;
|
||||
|
||||
if (!participant) {
|
||||
remove = true;
|
||||
participant = tile.participant;
|
||||
removedTileKeys.push(tile.key);
|
||||
}
|
||||
|
||||
let presenter;
|
||||
|
||||
if (layout === "spotlight") {
|
||||
presenter = participant.isActiveSpeaker();
|
||||
} else {
|
||||
presenter = layout === lastLayoutRef.current ? tile.presenter : false;
|
||||
}
|
||||
|
||||
newTiles.push({
|
||||
key: participant.member.userId,
|
||||
participant,
|
||||
remove,
|
||||
presenter,
|
||||
});
|
||||
}
|
||||
|
||||
for (const participant of participants) {
|
||||
if (newTiles.some(({ key }) => participant.member.userId === key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Added tiles
|
||||
newTiles.push({
|
||||
key: participant.member.userId,
|
||||
participant,
|
||||
remove: false,
|
||||
presenter: layout === "spotlight" && participant.isActiveSpeaker(),
|
||||
});
|
||||
}
|
||||
|
||||
newTiles.sort((a, b) => (b.presenter ? 1 : 0) - (a.presenter ? 1 : 0));
|
||||
|
||||
if (removedTileKeys.length > 0) {
|
||||
setTimeout(() => {
|
||||
if (!isMounted.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTileState(({ tiles }) => {
|
||||
const newTiles = tiles.filter(
|
||||
(tile) => !removedTileKeys.includes(tile.key)
|
||||
);
|
||||
|
||||
const presenterTileCount = newTiles.reduce(
|
||||
(count, tile) => count + (tile.presenter ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
return {
|
||||
tiles: newTiles,
|
||||
tilePositions: getTilePositions(
|
||||
newTiles.length,
|
||||
presenterTileCount,
|
||||
gridBounds.width,
|
||||
gridBounds.height
|
||||
),
|
||||
};
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
const presenterTileCount = newTiles.reduce(
|
||||
(count, tile) => count + (tile.presenter ? 1 : 0),
|
||||
0
|
||||
);
|
||||
|
||||
lastLayoutRef.current = layout;
|
||||
|
||||
return {
|
||||
tiles: newTiles,
|
||||
tilePositions: getTilePositions(
|
||||
newTiles.length,
|
||||
presenterTileCount,
|
||||
gridBounds.width,
|
||||
gridBounds.height
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [participants, gridBounds, layout]);
|
||||
|
||||
const animate = useCallback(
|
||||
(tiles) => (tileIndex) => {
|
||||
const tile = tiles[tileIndex];
|
||||
const tilePosition = tilePositions[tileIndex];
|
||||
const draggingTile = draggingTileRef.current;
|
||||
const dragging = draggingTile && tile.key === draggingTile.key;
|
||||
const remove = tile.remove;
|
||||
|
||||
if (dragging) {
|
||||
return {
|
||||
width: tilePosition.width,
|
||||
height: tilePosition.height,
|
||||
x: draggingTile.offsetX + draggingTile.x,
|
||||
y: draggingTile.offsetY + draggingTile.y,
|
||||
scale: 1.1,
|
||||
opacity: 1,
|
||||
zIndex: 1,
|
||||
shadow: 15,
|
||||
immediate: (key) => key === "zIndex" || key === "x" || key === "y",
|
||||
from: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
},
|
||||
reset: false,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...tilePosition,
|
||||
scale: remove ? 0 : 1,
|
||||
opacity: remove ? 0 : 1,
|
||||
zIndex: 0,
|
||||
shadow: 1,
|
||||
from: {
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
},
|
||||
reset: false,
|
||||
immediate: (key) => key === "zIndex",
|
||||
};
|
||||
}
|
||||
},
|
||||
[tiles, tilePositions]
|
||||
);
|
||||
|
||||
const [springs, api] = useSprings(tiles.length, animate(tiles), [
|
||||
tilePositions,
|
||||
tiles,
|
||||
]);
|
||||
|
||||
const onTap = useCallback(
|
||||
(tileKey) => {
|
||||
const lastTapped = lastTappedRef.current[tileKey];
|
||||
|
||||
if (!lastTapped || Date.now() - lastTapped > 500) {
|
||||
lastTappedRef.current[tileKey] = Date.now();
|
||||
return;
|
||||
}
|
||||
|
||||
lastTappedRef.current[tileKey] = 0;
|
||||
|
||||
const tile = tiles.find((tile) => tile.key === tileKey);
|
||||
|
||||
if (!tile) {
|
||||
return;
|
||||
}
|
||||
|
||||
const participant = tile.participant;
|
||||
|
||||
setTileState((state) => {
|
||||
let presenterTileCount = 0;
|
||||
|
||||
const newTiles = state.tiles.map((tile) => {
|
||||
let newTile = tile;
|
||||
|
||||
if (tile.participant === participant) {
|
||||
newTile = { ...tile, presenter: !tile.presenter };
|
||||
}
|
||||
|
||||
if (newTile.presenter) {
|
||||
presenterTileCount++;
|
||||
}
|
||||
|
||||
return newTile;
|
||||
});
|
||||
|
||||
newTiles.sort((a, b) => (b.presenter ? 1 : 0) - (a.presenter ? 1 : 0));
|
||||
|
||||
presenterTileCount;
|
||||
|
||||
return {
|
||||
...state,
|
||||
tiles: newTiles,
|
||||
tilePositions: getTilePositions(
|
||||
newTiles.length,
|
||||
presenterTileCount,
|
||||
gridBounds.width,
|
||||
gridBounds.height
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
[tiles, gridBounds]
|
||||
);
|
||||
|
||||
const bind = useDrag(
|
||||
({ args: [key], active, xy, movement, tap, event }) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (tap) {
|
||||
onTap(key);
|
||||
return;
|
||||
}
|
||||
|
||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
|
||||
const dragTile = tiles[dragTileIndex];
|
||||
const dragTilePosition = tilePositions[dragTileIndex];
|
||||
|
||||
let newTiles = tiles;
|
||||
|
||||
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
|
||||
|
||||
for (
|
||||
let hoverTileIndex = 0;
|
||||
hoverTileIndex < tiles.length;
|
||||
hoverTileIndex++
|
||||
) {
|
||||
const hoverTile = tiles[hoverTileIndex];
|
||||
const hoverTilePosition = tilePositions[hoverTileIndex];
|
||||
|
||||
if (hoverTile.key === key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isInside(cursorPosition, hoverTilePosition)) {
|
||||
newTiles = moveArrItem(tiles, dragTileIndex, hoverTileIndex);
|
||||
|
||||
newTiles = newTiles.map((tile) => {
|
||||
if (tile === hoverTile) {
|
||||
return { ...tile, presenter: dragTile.presenter };
|
||||
} else if (tile === dragTile) {
|
||||
return { ...tile, presenter: hoverTile.presenter };
|
||||
} else {
|
||||
return tile;
|
||||
}
|
||||
});
|
||||
|
||||
newTiles.sort(
|
||||
(a, b) => (b.presenter ? 1 : 0) - (a.presenter ? 1 : 0)
|
||||
);
|
||||
|
||||
setTileState((state) => ({ ...state, tiles: newTiles }));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (!draggingTileRef.current) {
|
||||
draggingTileRef.current = {
|
||||
key: dragTile.key,
|
||||
offsetX: dragTilePosition.x,
|
||||
offsetY: dragTilePosition.y,
|
||||
};
|
||||
}
|
||||
|
||||
draggingTileRef.current.x = movement[0];
|
||||
draggingTileRef.current.y = movement[1];
|
||||
} else {
|
||||
draggingTileRef.current = null;
|
||||
}
|
||||
|
||||
api.start(animate(newTiles));
|
||||
},
|
||||
{ filterTaps: true, enabled: layout === "gallery" }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.grid} ref={gridRef}>
|
||||
{springs.map(({ shadow, ...style }, i) => {
|
||||
const tile = tiles[i];
|
||||
|
||||
return (
|
||||
<ParticipantTile
|
||||
{...bind(tile.key)}
|
||||
key={tile.key}
|
||||
style={{
|
||||
boxShadow: shadow.to(
|
||||
(s) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px 0px`
|
||||
),
|
||||
...style,
|
||||
}}
|
||||
{...tile}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
VideoGrid.defaultProps = {
|
||||
layout: "gallery",
|
||||
};
|
||||
|
||||
function ParticipantTile({ style, participant, remove, presenter, ...rest }) {
|
||||
const videoRef = useRef();
|
||||
|
||||
useEffect(() => {
|
||||
if (participant.usermediaStream) {
|
||||
// Mute the local video
|
||||
// TODO: Should GroupCallParticipant have a local field?
|
||||
if (!participant.call) {
|
||||
videoRef.current.muted = true;
|
||||
}
|
||||
|
||||
videoRef.current.srcObject = participant.usermediaStream;
|
||||
videoRef.current.play();
|
||||
} else {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
}, [participant.usermediaStream]);
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<animated.div className={styles.participantTile} style={style} {...rest}>
|
||||
<div
|
||||
className={classNames(styles.participantName, {
|
||||
[styles.speaking]: participant.usermediaFeed?.isSpeaking(),
|
||||
})}
|
||||
>
|
||||
{participant.usermediaFeed?.isSpeaking() ? (
|
||||
<MicIcon />
|
||||
) : participant.isAudioMuted() ? (
|
||||
<MuteMicIcon className={styles.muteMicIcon} />
|
||||
) : null}
|
||||
<span>{participant.member.rawDisplayName}</span>
|
||||
</div>
|
||||
{participant.videoMuted && (
|
||||
<DisableVideoIcon
|
||||
className={styles.videoMuted}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
)}
|
||||
<video ref={videoRef} playsInline disablePictureInPicture />
|
||||
</animated.div>
|
||||
);
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
/*
|
||||
Copyright 2021 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.
|
||||
*/
|
||||
|
||||
.grid {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.participantTile {
|
||||
position: absolute;
|
||||
will-change: transform, width, height, opacity, box-shadow;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
background-color: #444;
|
||||
}
|
||||
|
||||
.participantTile * {
|
||||
touch-action: none;
|
||||
-moz-user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.participantTile video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.participantName {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
height: 32px;
|
||||
padding: 0 8px;
|
||||
background-color: rgba(23, 25, 28, 0.96);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.participantName > * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.participantName > :last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.participantName span {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
.muteMicIcon * {
|
||||
fill: #FF5B55;
|
||||
}
|
||||
|
||||
.videoMuted {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.participantTile:hover .participantName, .participantName.speaking {
|
||||
display: flex;
|
||||
}
|
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"target": "es2016",
|
||||
"noImplicitAny": false,
|
||||
"sourceMap": false,
|
||||
"declaration": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react",
|
||||
"lib": ["es2019", "dom", "dom.iterable"]
|
||||
}
|
||||
}
|
@ -15,12 +15,11 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { defineConfig } from "vite";
|
||||
import reactRefresh from "@vitejs/plugin-react-refresh";
|
||||
import svgrPlugin from "vite-plugin-svgr";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [reactRefresh(), svgrPlugin()],
|
||||
plugins: [svgrPlugin()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/_matrix": "http://localhost:8008",
|
||||
|
Loading…
Reference in New Issue
Block a user