Repository: muxinc/meet
Branch: main
Commit: 51eb0fbf9f79
Files: 82
Total size: 155.8 KB
Directory structure:
gitextract_6okmy78h/
├── .eslintrc.json
├── .gitignore
├── .husky/
│ └── pre-commit
├── .prettierignore
├── .prettierrc.json
├── LICENSE
├── README.md
├── client/
│ └── token.ts
├── components/
│ ├── Controls.tsx
│ ├── Gallery.tsx
│ ├── GalleryLayout.tsx
│ ├── Header.tsx
│ ├── Meeting.tsx
│ ├── MuteIndicator.tsx
│ ├── Notifications.tsx
│ ├── Participant.tsx
│ ├── ParticipantAudio.tsx
│ ├── ParticipantInfoBar.tsx
│ ├── ParticipantName.tsx
│ ├── Pin.tsx
│ ├── Sounds.tsx
│ ├── Stage.tsx
│ ├── Timer.tsx
│ ├── Toasts.tsx
│ ├── UserInteractionPrompt.tsx
│ ├── controls/
│ │ ├── ControlsCenter.tsx
│ │ ├── ControlsLeft.tsx
│ │ ├── ControlsRight.tsx
│ │ └── buttons/
│ │ ├── CameraButton.tsx
│ │ ├── ChatButton.tsx
│ │ ├── MicrophoneButton.tsx
│ │ ├── ScreenShareButton.tsx
│ │ ├── SendButton.tsx
│ │ └── SettingsButton.tsx
│ ├── icons/
│ │ ├── ChatIcon.tsx
│ │ ├── ChevronIcon.tsx
│ │ ├── ChevronLeftIcon.tsx
│ │ ├── ChevronRightIcon.tsx
│ │ ├── LeaveIcon.tsx
│ │ ├── MuteCameraIcon.tsx
│ │ ├── MuteMicrophoneIcon.tsx
│ │ ├── ScreenShareIcon.tsx
│ │ ├── SendIcon.tsx
│ │ ├── SettingsIcon.tsx
│ │ ├── UnmuteCameraIcon.tsx
│ │ └── UnmuteMicrophoneIcon.tsx
│ ├── modals/
│ │ ├── ACRScoreDialog.tsx
│ │ ├── ErrorModal.tsx
│ │ └── RenameParticipantModal.tsx
│ └── renderers/
│ ├── AudioRenderer.tsx
│ ├── ChatRenderer.tsx
│ ├── ScreenShareRenderer.tsx
│ └── VideoRenderer.tsx
├── context/
│ ├── Chat.tsx
│ ├── Space.tsx
│ ├── User.tsx
│ └── UserMedia.tsx
├── hooks/
│ ├── useLocalStorage.tsx
│ ├── useParticipant.ts
│ ├── useSpace.ts
│ ├── useSpaceEvent.tsx
│ ├── useUserMedia.ts
│ └── useWindowDimension.ts
├── lib/
│ ├── constants.ts
│ ├── gallery.ts
│ ├── theme.ts
│ └── utils.ts
├── next-env.d.ts
├── next.config.js
├── package.json
├── pages/
│ ├── _app.tsx
│ ├── _document.tsx
│ ├── api/
│ │ ├── spaces/
│ │ │ └── [id].ts
│ │ ├── spaces.ts
│ │ ├── token.ts
│ │ └── webhooks.ts
│ ├── index.tsx
│ └── space/
│ └── [id].tsx
├── server-lib/
│ └── services.ts
├── shared/
│ ├── defaults.tsx
│ └── toastConfigs.tsx
└── tsconfig.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .eslintrc.json
================================================
{
"extends": ["next/core-web-vitals", "prettier"]
}
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
# typescript
*.tsbuildinfo
# VScode
.vscode
================================================
FILE: .husky/pre-commit
================================================
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
================================================
FILE: .prettierignore
================================================
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# typescript
*.tsbuildinfo
================================================
FILE: .prettierrc.json
================================================
{}
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2023 Mux
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Mux Meet
> [!WARNING]
> Mux Real-Time Video has been sunset and is unavailable for new usage. Existing access will end on December 31, 2023. [We recommend migrating your application to our partner, LiveKit](https://livekit.io/mux-livekit). Please contact [Mux Support](https://mux.com/support) if you need more help or details. Mux Meet is no-longer maintained and the repository will soon be archived.
Mux Meet is a video conferencing app powered by [Mux Real-Time Video](https://mux.com/real-time-video), written in React, using the [Next.js](https://nextjs.org/) framework.

# Getting Started
In order for Meet to connect to Mux's APIs, an access token and signing key must be provided. These are generated in the Mux Dashboard and should be set as environment variables.
The easiest way to use Mux Meet is to deploy it to Vercel.
[](<https://vercel.com/new/clone?demo-title=Mux%20Meet&demo-description=Real-time%20conferencing%20(meeting)%20SaaS%20app%2C%20built%20with%20Next.js%2C%20Mux%2C%20and%20Vercel&demo-url=https%3A%2F%2Fmuxmeet.vercel.app%2F&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F7ISNSvmomH7w7KUPCy8gn0%2F97ab315dcd21aa1d4b23e54dc123b562%2Fmux-meet.png&project-name=Mux%20Meet&repository-name=mux-meet&repository-url=https%3A%2F%2Fgithub.com%2Fmuxinc%2Fmeet&from=templates&skippable-integrations=1&env=MUX_TOKEN_ID%2CMUX_TOKEN_SECRET%2CMUX_SIGNING_KEY%2CMUX_PRIVATE_KEY&envDescription=How%20to%20get%20these%20env%20variables%3A&envLink=https%3A%2F%2Fgithub.com%2Fmuxinc%2Fmeet%23getting-started>)
After creating your project, you will be prompted to configure it.

In a separate window, open https://dashboard.mux.com and sign in. You will need to create an account, if you don't already have one.
From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Access Tokens. Then click "Generate new token" and select Mux Video from the list of permissions. Optionally, give the access token a name like Mux Meet.

Once your token is generated, copy and paste the ID and Secret as the values for `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` in Vercel.
Now go back to https://dashboard.mux.com to generate the Signing Key.
From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Signing Keys. Then click "Generate new key" and make sure you use the same environment as you did for the Access Token. The Product selection should default to Video.

Once your key is generated, copy and paste the ID and Secret as the values for `MUX_SIGNING_KEY` and `MUX_PRIVATE_KEY` in Vercel.
_Both the Access Token and Signing Key are sensitive. DO NOT MAKE THEM PUBLIC. It is safe to store them in Vercel as Environment Variables or locally during development._
Once all 4 environment variables are filled in. Click Deploy for Vercel to build and start your app.
## Cleanup Spaces after Meeting
Joining a new Space creates a Space in Mux, but in order to clean up Spaces after a meeting is over, set up a simple [webhook from Mux](https://docs.mux.com/guides/video/listen-for-webhooks).
From the [Mux Dashboard](https://dashboard.mux.com), open Settings from the bottom left and click Webhooks. Then click "Generate new webhook" and make sure you use the same environment as you did for the Access Token. The URL to notify will be your app's URL + `/api/webhooks`.

Now generate the Webhook and copy the Signing Secret by clicking Show Signing Secret.
Configure your deployed app with a new environment variable named `WEBHOOK_SECRET` with the value of the Webhook Signing Secret.
_Make sure you redeploy for your new environment variable to take affect._
## Limit Access
To limit the number of active Spaces, set an integer value for an environment variable `ACTIVE_SPACE_LIMIT`.
To limit the amount of time participants are allowed to spend in a temporary Space, set an integer value in seconds for an environment variable `SPACE_DURATION_SECONDS`.
# Develop locally
Create an .env.local file at the root of the repo with the following secrets:
```bash
MUX_TOKEN_ID=
MUX_TOKEN_SECRET=
MUX_SIGNING_KEY=
MUX_PRIVATE_KEY=
```
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
# Learn More
## Mux
- [Mux Real-Time Video](https://mux.com/real-time-video)
- [Real-Time Video in React](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-a-react-application)
- [Real-Time Video on Android](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-android-application)
- [Real-Time Video on iOS](https://docs.mux.com/guides/video/send-and-receive-real-time-video-from-an-ios-application)
## Next.js
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
================================================
FILE: client/token.ts
================================================
import axios from "axios";
interface TokenParams {
spaceId: string;
participantId: string;
}
interface TokenResponse {
spaceJWT: string;
}
export const tokenPOST = async (
params: TokenParams
): Promise<TokenResponse> => {
return axios.post(`/api/token`, params).then(({ data }) => data);
};
================================================
FILE: components/Controls.tsx
================================================
import React, { useCallback } from "react";
import { useRouter } from "next/router";
import { Flex, useDisclosure } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import ControlsRight from "./controls/ControlsRight";
import ControlsLeft from "./controls/ControlsLeft";
import ControlsCenter from "./controls/ControlsCenter";
import ACRScoreDialog from "./modals/ACRScoreDialog";
export default function Controls(): JSX.Element {
const router = useRouter();
const { leaveSpace } = useSpace();
const { isOpen: isACRScoreDialogOpen, onOpen: onACRScoreDialogOpen } =
useDisclosure();
const leaveSpacePage = useCallback(() => {
router.push("/");
}, [router]);
const promptForACR = useCallback(() => {
try {
leaveSpace();
onACRScoreDialogOpen();
} catch (error) {
console.error(`Unable to properly leave space: ${error}`);
leaveSpacePage();
}
}, [leaveSpace, leaveSpacePage, onACRScoreDialogOpen]);
return (
<>
<ACRScoreDialog isOpen={isACRScoreDialogOpen} onClose={leaveSpacePage} />
<Flex
alignItems="center"
backgroundColor="#0a0a0b"
bottom="0px"
flexDirection="row"
height={{ base: "60px", sm: "80px" }}
justifyContent="space-between"
left="0px"
padding="10px 40px"
position="fixed"
width="100%"
zIndex={1000}
>
<ControlsLeft />
{!isACRScoreDialogOpen && (
<>
<ControlsCenter onLeave={promptForACR} />
<ControlsRight onLeave={promptForACR} />
</>
)}
</Flex>
</>
);
}
================================================
FILE: components/Gallery.tsx
================================================
import React, { useCallback, useMemo, useState } from "react";
import { IconButton, Center, Flex } from "@chakra-ui/react";
import UserContext from "context/User";
import { useSpace } from "hooks/useSpace";
import Participant from "./Participant";
import GalleryLayout from "./GalleryLayout";
import ParticipantAudio from "./ParticipantAudio";
import ChevronLeftIcon from "components/icons/ChevronLeftIcon";
import ChevronRightIcon from "components/icons/ChevronRightIcon";
function pushToFront<T>(array: T[], element: T) {
const index = array.findIndex((el) => el === element);
if (index) {
array.splice(index, 1);
array.unshift(element);
}
}
interface Props {
gap: number;
width: number;
height: number;
participantsPerPage: number;
}
export default function Gallery({
gap,
width,
height,
participantsPerPage,
}: Props): JSX.Element {
const [currentPage, setCurrentPage] = useState(1);
const {
connectionIds,
participantCount,
localParticipantConnectionId,
screenShareParticipantConnectionId,
} = useSpace();
const { pinnedConnectionId } = React.useContext(UserContext);
const orderedConnectionIds = useMemo(() => {
const ids = [...connectionIds];
[
screenShareParticipantConnectionId,
pinnedConnectionId,
localParticipantConnectionId,
].forEach((id) => {
if (id) {
pushToFront(ids, id);
}
});
return ids;
}, [
connectionIds,
localParticipantConnectionId,
pinnedConnectionId,
screenShareParticipantConnectionId,
]);
const numberPages = useMemo(() => {
if (participantCount >= participantsPerPage) {
return Math.ceil(participantCount / participantsPerPage);
} else {
return 1;
}
}, [participantCount, participantsPerPage]);
const goToPreviousPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage((page) => page - 1);
}
}, [currentPage]);
const paginatedConnectionIds = useMemo(() => {
const startIndex = currentPage * participantsPerPage - participantsPerPage;
const endIndex = startIndex + participantsPerPage;
const pageParticipants = orderedConnectionIds.slice(startIndex, endIndex);
// if there are no participants, then only the local view will show up on the page
// we need to go back to the previous page.
if (pageParticipants.length === 0) {
goToPreviousPage();
}
return pageParticipants;
}, [
orderedConnectionIds,
currentPage,
participantsPerPage,
goToPreviousPage,
]);
const hidePaginateCtrlRight = currentPage === numberPages;
const hidePaginateCtrlLeft = currentPage === 1;
const goToNextPage = () => {
if (currentPage < numberPages) {
setCurrentPage((page) => page + 1);
}
};
const widthBetweenPagination = numberPages === 1 ? width : width - 80;
return (
<Flex height="100%" justifyContent="space-between">
<Center w="40px" marginLeft="12px">
<IconButton
aria-label="Paginate left"
icon={<ChevronLeftIcon />}
isRound={true}
onClick={goToPreviousPage}
opacity={numberPages === 1 ? 0 : 1}
hidden={hidePaginateCtrlLeft}
variant="outline"
border="1px"
borderColor="#666666"
backgroundColor="#383838"
_hover={{
border: "1px solid #CCCCCC",
backgroundColor: "#383838",
}}
_active={{
border: "1px solid #CCCCCC",
backgroundColor: "#444444",
}}
/>
</Center>
<Center width={width} zIndex={100}>
{connectionIds.map((connectionId) => (
<ParticipantAudio key={connectionId} connectionId={connectionId} />
))}
<GalleryLayout
width={widthBetweenPagination}
height={height}
gap={gap - 6}
>
{paginatedConnectionIds.map((connectionId) => {
return (
<Participant key={connectionId} connectionId={connectionId} />
);
})}
</GalleryLayout>
</Center>
<Center w="40px" marginRight="12px">
<IconButton
aria-label="Paginate right"
icon={<ChevronRightIcon />}
isRound={true}
opacity={numberPages === 1 ? 0 : 1}
hidden={hidePaginateCtrlRight}
variant="outline"
border="1px"
borderColor="#666666"
onClick={goToNextPage}
backgroundColor="#383838"
_hover={{
border: "1px solid #CCCCCC",
backgroundColor: "#383838",
}}
_active={{
border: "1px solid #CCCCCC",
backgroundColor: "#444444",
}}
zIndex={2}
/>
</Center>
</Flex>
);
}
================================================
FILE: components/GalleryLayout.tsx
================================================
import React, {
Children,
cloneElement,
isValidElement,
ReactNode,
useMemo,
} from "react";
import { Flex } from "@chakra-ui/react";
import { calcOptimalBoxes } from "lib/gallery";
interface Props {
gap?: number;
width: number;
height: number;
children: ReactNode;
}
const GalleryLayout = ({ children, width, height, gap = 10 }: Props) => {
const bestFit = useMemo(() => {
if (children) {
return calcOptimalBoxes(
width,
height,
Children.count(children),
16 / 9,
gap
);
}
}, [children, width, height, gap]);
return (
<Flex
wrap="wrap"
width="100%"
height="100%"
gap={`${gap}px`}
overflow="hidden"
alignItems="center"
alignContent="center"
justifyContent="center"
>
{Children.map(children, (child) => {
if (
isValidElement(child) &&
bestFit &&
bestFit.width &&
bestFit.height
) {
return cloneElement(child as React.ReactElement<any>, {
width: bestFit.width,
height: bestFit.height,
});
} else {
return child;
}
})}
</Flex>
);
};
export default GalleryLayout;
================================================
FILE: components/Header.tsx
================================================
import React from "react";
import Image from "next/image";
import { AiFillGithub } from "react-icons/ai";
import { Flex, Spacer } from "@chakra-ui/react";
import muxLogo from "../public/mux-logo.svg";
export default function Header(): JSX.Element {
return (
<Flex
zIndex={2}
padding="2"
alignItems="center"
justifyContent="space-between"
backgroundColor="#292929"
borderBottom="1px solid #e8e8e8"
>
<Flex alignItems="center" padding="10px" width="290px">
<a
href="https://www.mux.com/real-time-video"
target="_blank"
rel="noreferrer"
>
<Image
priority
alt="logo"
width={150}
src={muxLogo}
style={{ height: "auto" }}
/>
</a>
</Flex>
<Spacer />
<Flex alignItems="center" padding="10px">
<a
href="https://github.com/muxinc/meet"
target="_blank"
rel="noreferrer"
>
<AiFillGithub color="white" size="40px" />
</a>
</Flex>
</Flex>
);
}
================================================
FILE: components/Meeting.tsx
================================================
import React, { useContext } from "react";
import { Center, Flex } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import useWindowDimensions from "hooks/useWindowDimension";
import Gallery from "./Gallery";
import Timer from "./Timer";
import ScreenShareRenderer from "./renderers/ScreenShareRenderer";
import ChatRenderer from "./renderers/ChatRenderer";
import ChatContext from "context/Chat";
const headerHeight = 80;
const chatWidth = 300;
export default function Meeting(): JSX.Element {
let gap = 10;
const {
participantCount,
attachScreenShare,
isScreenShareActive,
spaceEndsAt,
} = useSpace();
const { isChatOpen } = useContext(ChatContext);
const { width = 0, height = 0 } = useWindowDimensions();
const availableWidth = width - (isChatOpen && width > 800 ? chatWidth : 0);
const paddingY = height < 600 ? 10 : 40;
const paddingX = availableWidth < 800 ? 40 : 60;
let galleryWidth = availableWidth - paddingX * 2;
if (isScreenShareActive) {
if (participantCount < 6) {
galleryWidth = availableWidth * 0.25 - paddingX;
} else {
galleryWidth = availableWidth * 0.33 - paddingX / 2;
}
galleryWidth = Math.max(galleryWidth, 160);
}
let galleryHeight = height - headerHeight - paddingY * 2;
let screenShareWidth = isScreenShareActive
? availableWidth - galleryWidth
: 0;
let direction: "row" | "column" = "row";
if (width < height) {
gap = 8;
galleryWidth = availableWidth - paddingX * 2;
if (isScreenShareActive) {
direction = "column";
screenShareWidth = availableWidth;
galleryHeight = height - headerHeight - (availableWidth / 4) * 3;
}
}
let scaleFactor = 2.25;
const rows = Math.max(Math.ceil(galleryHeight / (90 * scaleFactor)), 1);
const columns = Math.max(Math.ceil(galleryWidth / (160 * scaleFactor)), 1);
const participantsPerPage = Math.round(rows * columns);
return (
<Flex width="100%" height="100%" direction="row" position="relative">
{spaceEndsAt && <Timer />}
<Flex
maxWidth={availableWidth}
height="100%"
alignItems="center"
justifyContent="center"
direction={direction}
flexGrow={1}
>
<Center width={`${screenShareWidth}px`} maxHeight="100%">
<ScreenShareRenderer attach={attachScreenShare} />
</Center>
<Gallery
gap={gap}
width={galleryWidth}
height={galleryHeight}
participantsPerPage={participantsPerPage}
/>
</Flex>
<ChatRenderer show={width > 800 && isChatOpen} />
</Flex>
);
}
================================================
FILE: components/MuteIndicator.tsx
================================================
import React from "react";
import { Box, Flex, Icon } from "@chakra-ui/react";
import { IoMicOffOutline } from "react-icons/io5";
interface Props {
isMuted: boolean;
parentHeight: number;
}
export default function MuteIndicator({
isMuted,
parentHeight,
}: Props): JSX.Element {
let left = "2px";
let bottom = "15%";
let marginLeft = "3";
let iconWidth = "5";
let iconHeight = "5";
let borderRadius = "10px";
let paddingX = "2";
let paddingY = "1";
if (parentHeight <= 250) {
left = "0";
bottom = "5%";
marginLeft = "1";
}
if (parentHeight <= 200) {
paddingX = "1";
borderRadius = "0 10px 0 0";
bottom = "0";
marginLeft = "0";
iconWidth = "12px";
iconHeight = "12px";
}
if (parentHeight <= 90) {
paddingX = "0";
paddingY = "1.5px";
borderRadius = "0";
}
return (
<Box
_groupHover={{
opacity: 1,
backgroundColor: "transparent",
}}
background="rgba(68, 68, 68, 0.75)"
borderRadius={borderRadius}
color="white"
marginLeft={marginLeft}
marginY="0"
py={paddingY}
zIndex={10}
position="absolute"
left={left}
bottom={bottom}
>
{isMuted && (
<Flex alignItems="center" color="#FFFFFF" px={paddingX}>
<Icon w={iconWidth} h={iconHeight} as={IoMicOffOutline} />
</Flex>
)}
</Box>
);
}
================================================
FILE: components/Notifications.tsx
================================================
import { useState, useEffect } from "react";
import { useDisclosure } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import { useUserMedia } from "hooks/useUserMedia";
import Sounds from "./Sounds";
import Toasts from "./Toasts";
import ErrorModal from "./modals/ErrorModal";
export default function Notifications(): JSX.Element {
const { joinError, screenShareError } = useSpace();
const { userMediaError } = useUserMedia();
const [errorModalTitle, setErrorModalTitle] = useState("");
const [errorModalMessage, setErrorModalMessage] = useState("");
const {
isOpen: isErrorModalOpen,
onOpen: onErrorModalOpen,
onClose: onErrorModalClose,
} = useDisclosure();
useEffect(() => {
if (joinError) {
setErrorModalTitle("Error joining space");
setErrorModalMessage(joinError);
onErrorModalOpen();
}
}, [joinError, onErrorModalOpen]);
useEffect(() => {
if (screenShareError === "Permission denied by system") {
setErrorModalTitle("Can't share your screen");
setErrorModalMessage(
"Please check your browser has screen capture permissions and try restarting your browser if you continue to have issues."
);
onErrorModalOpen();
}
}, [screenShareError, onErrorModalOpen]);
useEffect(() => {
if (userMediaError === "NotAllowedError") {
setErrorModalTitle("Can't show your media");
setErrorModalMessage(
"Please check your browser has media capture (webcam and microphone) permissions and try restarting your browser if you continue to have issues."
);
onErrorModalOpen();
}
if (userMediaError === "OverconstrainedError") {
}
return () => {
onErrorModalClose();
};
}, [userMediaError, onErrorModalOpen, onErrorModalClose]);
return (
<>
<Sounds />
<Toasts />
<ErrorModal
title={errorModalTitle}
message={errorModalMessage}
isOpen={isErrorModalOpen}
onClose={onErrorModalClose}
/>
</>
);
}
================================================
FILE: components/Participant.tsx
================================================
import React, { useMemo } from "react";
import { Box, Center, Flex } from "@chakra-ui/react";
import UserContext from "context/User";
import { useParticipant } from "hooks/useParticipant";
import Pin from "./Pin";
import VideoRenderer from "./renderers/VideoRenderer";
import ParticipantInfoBar from "./ParticipantInfoBar";
import ParticipantName from "./ParticipantName";
interface Props {
width?: number;
height?: number;
connectionId: string;
}
export default function Participant({
width,
height,
connectionId,
}: Props): JSX.Element {
const {
id,
isLocal,
isSpeaking,
hasMicTrack,
isMicTrackMuted,
isCameraOff,
cameraWidth,
cameraHeight,
displayName,
attachVideoElement,
} = useParticipant(connectionId);
const outlineWidth = 3;
return (
<Box
width={`${width! - outlineWidth * 2}px`}
height={`${height! - outlineWidth * 2}px`}
minWidth="160px"
minHeight="90px"
background="black"
boxShadow={`0 0 0 ${
!isMicTrackMuted && isSpeaking ? outlineWidth : 1
}px ${!isMicTrackMuted && isSpeaking ? "#FA50B5" : "black"}`}
borderRadius="5px"
margin={`${outlineWidth}px`}
overflow="hidden"
position="relative"
role="group"
>
<VideoRenderer
local={isLocal}
width={cameraWidth}
height={cameraHeight}
attachFunc={attachVideoElement}
connectionId={connectionId}
/>
<ParticipantInfoBar
name={displayName || id}
isMuted={!hasMicTrack || isMicTrackMuted}
parentHeight={height!}
/>
{isCameraOff && (
<ParticipantName isSmall={width! <= 400}>
{displayName || id}
</ParticipantName>
)}
{!isLocal && <Pin connectionId={connectionId} />}
</Box>
);
}
================================================
FILE: components/ParticipantAudio.tsx
================================================
import React from "react";
import { useParticipant } from "hooks/useParticipant";
import AudioRenderer from "./renderers/AudioRenderer";
interface Props {
connectionId: string;
}
const ParticipantAudio = ({ connectionId }: Props) => {
const { isLocal, attachAudioElement } = useParticipant(connectionId);
return !isLocal ? <AudioRenderer attachFunc={attachAudioElement} /> : null;
};
export default ParticipantAudio;
================================================
FILE: components/ParticipantInfoBar.tsx
================================================
import React from "react";
import { Flex, Text } from "@chakra-ui/react";
import MuteIndicator from "./MuteIndicator";
interface Props {
name: string;
isMuted: boolean;
parentHeight: number;
}
export default function ParticipantInfoBar({
name,
isMuted,
parentHeight,
}: Props): JSX.Element {
let height = "40px";
let fontSize = "14px";
if (parentHeight <= 250) {
height = "30px";
}
if (parentHeight <= 200) {
height = "20px";
fontSize = "10px";
}
if (parentHeight <= 90) {
height = "15px";
fontSize = "10px";
}
return (
<Flex
position="absolute"
bottom="0"
left="0"
width="100%"
height={height}
color="transparent"
backgroundColor="transparent"
_groupHover={{
color: "white",
background: "rgba(50, 50, 50, 0.5)",
}}
justifyContent="center"
alignItems="center"
>
<MuteIndicator parentHeight={parentHeight} isMuted={isMuted} />
<Text fontSize={fontSize} fontWeight="700">
{name}
</Text>
</Flex>
);
}
================================================
FILE: components/ParticipantName.tsx
================================================
import { memo } from "react";
import { Box, Center, Flex } from "@chakra-ui/react";
interface Props {
isSmall: boolean;
children: string;
}
function ParticipantName({ isSmall, children }: Props): JSX.Element {
return (
<Center
background="black"
color="white"
fontSize={isSmall ? "20px" : "45px"}
h="100%"
position="absolute"
top="0"
w="100%"
>
<Flex width="100%" direction="column" textAlign="center">
<Box overflowWrap="anywhere">{children}</Box>
</Flex>
</Center>
);
}
const MemoizedName = memo(ParticipantName);
export default MemoizedName;
================================================
FILE: components/Pin.tsx
================================================
import React from "react";
import { IconButton } from "@chakra-ui/react";
import { MdPushPin, MdOutlinePushPin } from "react-icons/md";
import UserContext from "context/User";
interface Props {
connectionId: string;
}
export default function Pin({ connectionId }: Props): JSX.Element {
const { pinnedConnectionId, setPinnedConnectionId } =
React.useContext(UserContext);
return (
<IconButton
size="sm"
opacity={pinnedConnectionId === connectionId ? 1 : 0}
_groupHover={{
opacity: 1,
}}
aria-label="pin"
onClick={() => {
if (setPinnedConnectionId) {
if (pinnedConnectionId === connectionId) {
setPinnedConnectionId("");
} else {
setPinnedConnectionId(connectionId);
}
}
}}
variant="ghost"
position="absolute"
right={0}
top={0}
icon={
pinnedConnectionId === connectionId ? (
<MdPushPin />
) : (
<MdOutlinePushPin />
)
}
/>
);
}
================================================
FILE: components/Sounds.tsx
================================================
import useSound from "use-sound";
import { SpaceEvent } from "@mux/spaces-web";
import { useSpaceEvent } from "hooks/useSpaceEvent";
import { useCallback } from "react";
import { useSpace } from "hooks/useSpace";
const participantSoundCutoff = 5;
export default function Sounds(): JSX.Element {
const { isJoined } = useSpace();
return <>{isJoined && <JoinedSounds />}</>;
}
function JoinedSounds(): JSX.Element {
const { participantCount } = useSpace();
const [playJoinSound] = useSound("/sounds/meet-join.mp3");
const [playLeaveSound] = useSound("/sounds/meet-leave.mp3");
useSpaceEvent(
SpaceEvent.ParticipantJoined,
useCallback(() => {
if (document["hidden"] && participantCount < participantSoundCutoff) {
playJoinSound();
}
}, [playJoinSound, participantCount])
);
useSpaceEvent(
SpaceEvent.ParticipantLeft,
useCallback(() => {
if (document["hidden"] && participantCount < participantSoundCutoff) {
playLeaveSound();
}
}, [playLeaveSound, participantCount])
);
return <></>;
}
================================================
FILE: components/Stage.tsx
================================================
import React from "react";
import { Center, Heading, Spinner } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import Controls from "./Controls";
import Meeting from "./Meeting";
import Notifications from "./Notifications";
import { ChatProvider } from "context/Chat";
const LoadingSpinner = () => {
return (
<>
<Spinner color="#FA50B5" size="xl" />
<Heading color="white" ml={5}>
Joining Space...
</Heading>
</>
);
};
export default function Stage(): JSX.Element {
const { isJoined } = useSpace();
return (
<>
<Notifications />
<ChatProvider>
<Center height="calc(100% - 80px)" zIndex={1}>
{!isJoined ? <LoadingSpinner /> : <Meeting />}
</Center>
<Controls />
</ChatProvider>
</>
);
}
================================================
FILE: components/Timer.tsx
================================================
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { Tooltip } from "@chakra-ui/react";
import styled from "@emotion/styled";
import moment from "moment";
import { useSpace } from "hooks/useSpace";
import { transientOptions } from "lib/utils";
const Display = styled("div", transientOptions)<{ $isUrgent: boolean }>`
position: absolute;
display: flex;
flex-direction: column;
align-items: start;
background-color: #383838;
padding: 0.5em;
top: 12px;
left: 12px;
font-size: 1.5em;
font-variant-numeric: tabular-nums;
color: white;
user-select: none;
border-radius: 3px;
z-index: 200;
${(props) =>
props.$isUrgent &&
`
#countdown {
color: red;
}
`}
`;
const getClock = (diff: number) => {
const minutes = Math.floor((diff / 1000 / 60) % 60);
const seconds = Math.floor((diff / 1000) % 60);
const minuteDisplay = 10 > minutes ? "0" + minutes : minutes;
const secondDisplay = 10 > seconds ? "0" + seconds : seconds;
return `${minuteDisplay}:${secondDisplay}`;
};
const Timer = (): JSX.Element => {
const { spaceEndsAt, leaveSpace } = useSpace();
const diff = moment(spaceEndsAt).diff(moment());
const [timeDisplay, setTimeDisplay] = useState<string>(
spaceEndsAt ? getClock(diff) : "00:00"
);
const router = useRouter();
const [isTwoMinutesLeft, setIsTwoMinutesLeft] = useState(
120 >= Math.floor(diff / 1000)
);
useEffect(() => {
const timer = setInterval(() => {
const diff = moment(spaceEndsAt).diff(moment());
if (diff > 0) {
const clock = getClock(diff);
setTimeDisplay(getClock(diff));
setIsTwoMinutesLeft(120 >= Math.floor(diff / 1000));
} else {
leaveSpace();
router.push("/");
}
}, 1000);
return () => clearInterval(timer);
}, [router, leaveSpace, spaceEndsAt, isTwoMinutesLeft]);
return (
<Tooltip label="This demo space will close after the timer expires.">
<Display $isUrgent={isTwoMinutesLeft}>
<span>Time Left</span>
<span id="countdown">{timeDisplay}</span>
</Display>
</Tooltip>
);
};
export default Timer;
================================================
FILE: components/Toasts.tsx
================================================
import styled from "@emotion/styled";
import { useRouter } from "next/router";
import { useCallback, useEffect, useRef } from "react";
import { Box, ToastId, useToast } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import {
broadcastingToastConfig,
participantEventToastConfig,
sharingScreenToastConfig,
ToastIds,
viewingSharedScreenToastConfig,
} from "shared/toastConfigs";
const ToastBox = styled(Box)`
color: #0a0a0b;
background: #cff1fc;
font-size: 14px;
padding: 15px 50px;
text-align: center;
`;
export default function Toasts(): JSX.Element {
const toast = useToast();
const router = useRouter();
const { isReady: isRouterReady } = router;
const {
isBroadcasting,
isScreenShareActive,
stopScreenShare,
isLocalScreenShare,
screenShareParticipantName,
} = useSpace();
const screenshareToastRef = useRef<ToastId>();
const broadcastingToastRef = useRef<ToastId>();
const participantEventToastIdRefs = useRef<Array<ToastId>>([]);
const showLocalScreenshareToast = useCallback(() => {
if (!toast.isActive(ToastIds.SHARING_SCREEN_TOAST_ID)) {
screenshareToastRef.current = toast({
...sharingScreenToastConfig,
render: ({ onClose }) => {
return (
<ToastBox>
You are sharing your screen.
<Box
as="button"
backgroundColor="transparent"
border="1px solid #0A0A0B"
height="30px"
borderRadius="15px"
paddingLeft="15px"
paddingRight="15px"
marginLeft="20px"
onClick={() => {
stopScreenShare();
onClose();
}}
>
Stop sharing
</Box>
</ToastBox>
);
},
});
}
}, [toast, stopScreenShare]);
const showRemoteScreenshareToast = useCallback(() => {
if (
!toast.isActive(ToastIds.VIEWING_SHARED_SCREEN_TOAST_ID) &&
screenShareParticipantName
) {
screenshareToastRef.current = toast({
...viewingSharedScreenToastConfig,
render: () => (
<ToastBox>
{screenShareParticipantName} is sharing their screen.
</ToastBox>
),
});
}
}, [toast, screenShareParticipantName]);
const hideScreenshareToast = useCallback(() => {
if (screenshareToastRef.current) {
toast.close(screenshareToastRef.current);
}
}, [toast]);
const showBroadcastToast = useCallback(() => {
if (!toast.isActive(ToastIds.BROADCASTING_SCREEN_TOAST_ID)) {
broadcastingToastRef.current = toast({
...broadcastingToastConfig,
render: () => (
<ToastBox>{"⦿ Space is currently being broadcast"}</ToastBox>
),
});
}
}, [toast]);
const hideBroadcastToast = useCallback(() => {
if (broadcastingToastRef.current) {
toast.close(broadcastingToastRef.current);
}
}, [toast]);
const pruneParticipantEventRefs = useCallback(() => {
// Don't show more than 10 participant event toasts on screen at a time, close the oldest if necessary
while (participantEventToastIdRefs.current.length > 9) {
let oldest = participantEventToastIdRefs.current.shift();
if (oldest) {
toast.close(oldest);
}
}
// Prune ids of toasts that have already closed on their own
participantEventToastIdRefs.current =
participantEventToastIdRefs.current.filter((ref) => toast.isActive(ref));
}, [participantEventToastIdRefs, toast]);
const showParticipantEventToast = useCallback(
(eventDescription: string) => {
pruneParticipantEventRefs();
participantEventToastIdRefs.current.push(
toast({
...participantEventToastConfig,
render: () => <ToastBox>{eventDescription}</ToastBox>,
})
);
},
[toast, pruneParticipantEventRefs]
);
useEffect(() => {
if (isScreenShareActive) {
if (isLocalScreenShare) {
showLocalScreenshareToast();
} else {
showRemoteScreenshareToast();
}
} else {
hideScreenshareToast();
}
}, [
isScreenShareActive,
isLocalScreenShare,
showLocalScreenshareToast,
showRemoteScreenshareToast,
hideScreenshareToast,
]);
useEffect(() => {
if (isBroadcasting) {
showBroadcastToast();
} else {
hideBroadcastToast();
}
}, [isBroadcasting, showBroadcastToast, hideBroadcastToast]);
useEffect(() => {
if (!isRouterReady) return;
function closeAllToasts() {
hideBroadcastToast();
hideScreenshareToast();
for (let toastRef in participantEventToastIdRefs.current) {
toast.close(toastRef);
}
pruneParticipantEventRefs();
}
router.events.on("routeChangeStart", closeAllToasts);
return () => {
router.events.off("routeChangeStart", closeAllToasts);
};
}, [
isRouterReady,
router,
toast,
hideBroadcastToast,
hideScreenshareToast,
pruneParticipantEventRefs,
]);
return <></>;
}
================================================
FILE: components/UserInteractionPrompt.tsx
================================================
import React, {
useState,
useEffect,
useMemo,
useRef,
useContext,
MutableRefObject,
} from "react";
import Image from "next/image";
import {
Button,
Flex,
FormControl,
FormHelperText,
FormLabel,
HStack,
Input,
Stack,
} from "@chakra-ui/react";
import { HiOutlineArrowNarrowRight } from "react-icons/hi";
import UserContext from "context/User";
import { useUserMedia } from "hooks/useUserMedia";
import MicrophoneButton from "components/controls/buttons/MicrophoneButton";
import CameraButton from "components/controls/buttons/CameraButton";
import muxLogo from "../public/mux-logo.svg";
interface Props {
onInteraction: () => void;
participantNameRef: MutableRefObject<string>;
}
export default function UserInteractionPrompt({
onInteraction,
participantNameRef,
}: Props): JSX.Element {
const nameInputRef = useRef<HTMLInputElement>(null);
const didPopulateDevicesRef = useRef(false);
const user = useContext(UserContext);
const [participantName, setParticipantName] = useState("");
const [hasBlurredNameInput, setHasBlurredNameInput] = useState(false);
const { requestPermissionAndPopulateDevices } = useUserMedia();
useEffect(() => {
if (nameInputRef.current) {
nameInputRef.current.focus();
}
}, []);
useEffect(() => {
setParticipantName(user.participantName);
}, [user.participantName]);
const isNameInputInvalid = useMemo(
() => !participantName && hasBlurredNameInput,
[participantName, hasBlurredNameInput]
);
const handleParticipantNameChange = (event: {
target: { value: string };
}) => {
setParticipantName(event.target.value);
};
useEffect(() => {
if (didPopulateDevicesRef.current === false) {
didPopulateDevicesRef.current = true;
requestPermissionAndPopulateDevices();
}
}, [requestPermissionAndPopulateDevices]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
participantNameRef.current = participantName;
user.setParticipantName(participantName);
user.setInteractionRequired(false);
onInteraction();
};
return (
<Flex
gap="2rem"
height="100%"
overflow="hidden"
direction="column"
alignItems="center"
justifyContent="center"
>
<Image
priority
alt="logo"
width={300}
src={muxLogo}
style={{ zIndex: 0, height: "auto" }}
/>
<form onSubmit={handleSubmit}>
<Stack spacing="4">
<FormControl
isInvalid={isNameInputInvalid}
onBlur={() => setHasBlurredNameInput(true)}
>
<FormLabel textAlign="center" color="white">
Enter your name
</FormLabel>
<Input
placeholder="Your name"
color="white"
size="lg"
maxLength={40}
id="participant_name"
value={participantName}
onChange={handleParticipantNameChange}
variant="flushed"
isRequired={true}
ref={nameInputRef}
/>
<FormHelperText
color={!isNameInputInvalid ? "transparent" : "#E22C3E"}
>
This cannot be empty.
</FormHelperText>
</FormControl>
<Button
type="submit"
isDisabled={!participantName}
size="lg"
rightIcon={<HiOutlineArrowNarrowRight />}
variant="flushed"
color="white"
>
Join Space
</Button>
</Stack>
</form>
<HStack marginTop="2rem">
<MicrophoneButton />
<CameraButton />
</HStack>
</Flex>
);
}
================================================
FILE: components/controls/ControlsCenter.tsx
================================================
import React from "react";
import { HStack } from "@chakra-ui/react";
import MicrophoneButton from "./buttons/MicrophoneButton";
import CameraButton from "./buttons/CameraButton";
import ScreenShareButton from "./buttons/ScreenShareButton";
import SettingsButton from "./buttons/SettingsButton";
import ChatButton from "./buttons/ChatButton";
import useWindowDimensions from "hooks/useWindowDimension";
import { useSpace } from "hooks/useSpace";
interface Props {
onLeave: () => void;
}
export default function ControlsCenter({ onLeave }: Props): JSX.Element {
const { width = 0 } = useWindowDimensions();
const { isLocalScreenShareSupported } = useSpace();
return (
<HStack spacing="24px">
<MicrophoneButton />
<CameraButton />
{isLocalScreenShareSupported && <ScreenShareButton />}
{width > 800 && <ChatButton />}
<SettingsButton onLeave={onLeave} />
</HStack>
);
}
================================================
FILE: components/controls/ControlsLeft.tsx
================================================
import React from "react";
import Image from "next/image";
import { Flex } from "@chakra-ui/react";
import muxLogo from "../../public/mux-logo.svg";
export default function ControlsLeft(): JSX.Element {
return (
<Flex
alignItems="center"
display={{ base: "none", md: "flex" }}
width="290px"
>
<a
href="https://www.mux.com/real-time-video"
target="_blank"
rel="noreferrer"
>
<Image
priority
alt="logo"
width={150}
src={muxLogo}
style={{ height: "auto" }}
/>
</a>
</Flex>
);
}
================================================
FILE: components/controls/ControlsRight.tsx
================================================
import React from "react";
import { Button, Flex, Text } from "@chakra-ui/react";
import LeaveIcon from "components/icons/LeaveIcon";
import SpaceContext from "context/Space";
import styled from "@emotion/styled";
import useWindowDimensions from "hooks/useWindowDimension";
interface Props {
onLeave: () => void;
}
const ParticipantsLabel = styled.span`
color: #cccccc;
font-size: 12px;
line-height: 12px;
text-transform: uppercase;
letter-spacing: 0.8px;
`;
const ParticipantCount = styled.span`
font-size: 21px;
line-height: 21px;
letter-spacing: -0.5px;
margin-top: 5px;
`;
export default function ControlsRight({ onLeave }: Props): JSX.Element {
const { participantCount } = React.useContext(SpaceContext);
const { width } = useWindowDimensions();
const hideParticipantCount = (width && width < 1000) || false;
return (
<Flex
alignItems="center"
direction="row-reverse"
width="290px"
height="46px"
>
<Button
variant="muxDestructive"
display={{ base: "none", sm: "flex" }}
flexDirection="row"
marginLeft="30px"
padding="10px 20px"
onClick={onLeave}
>
<LeaveIcon />
<Text paddingLeft="10px">Leave</Text>
</Button>
<Flex
alignItems="center"
direction="column"
color="white"
fontWeight="bold"
marginTop="2px"
>
{!hideParticipantCount && (
<>
<ParticipantsLabel>Participants</ParticipantsLabel>
<ParticipantCount>{participantCount}</ParticipantCount>
</>
)}
</Flex>
</Flex>
);
}
================================================
FILE: components/controls/buttons/CameraButton.tsx
================================================
import React, { useCallback } from "react";
import {
Box,
ButtonGroup,
Flex,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Tooltip,
Text,
} from "@chakra-ui/react";
import { useHotkeys } from "react-hotkeys-hook";
import { AiOutlineCheck } from "react-icons/ai";
import UserContext from "context/User";
import { useSpace } from "hooks/useSpace";
import { useUserMedia } from "hooks/useUserMedia";
import ChevronIcon from "components/icons/ChevronIcon";
import MuteCameraIcon from "components/icons/MuteCameraIcon";
import UnmuteCameraIcon from "components/icons/UnmuteCameraIcon";
export default function CameraButton(): JSX.Element {
const { cameraOff, setCameraOff, cameraDeviceId, setCameraDeviceId } =
React.useContext(UserContext);
const { isJoined, publishCamera, unPublishDevice } = useSpace();
const { cameraDevices, stopActiveCamera } = useUserMedia();
const selectCameraDevice = useCallback(
async (deviceId: string) => {
setCameraDeviceId(deviceId);
if (isJoined && !cameraOff) {
publishCamera(deviceId);
}
},
[isJoined, setCameraDeviceId, cameraOff, publishCamera]
);
const toggleCamera = useCallback(async () => {
if (cameraOff) {
setCameraOff(false);
if (isJoined) {
publishCamera(cameraDeviceId);
}
} else {
setCameraOff(true);
stopActiveCamera();
if (isJoined) {
unPublishDevice(cameraDeviceId);
}
}
}, [
isJoined,
cameraOff,
setCameraOff,
cameraDeviceId,
stopActiveCamera,
publishCamera,
unPublishDevice,
]);
const hotkeyText = "⌘ + e"; // adding this here to make it easy to change later. appears in tooltip on button.
useHotkeys(
"meta+e",
() => {
toggleCamera();
},
{ preventDefault: true },
[toggleCamera]
);
const ariaLabel = cameraOff ? "Unhide" : "Hide";
return (
<ButtonGroup position="relative">
<Tooltip
label={
cameraOff
? `Enable Video (${hotkeyText})`
: `Disable Video (${hotkeyText})`
}
>
<IconButton
variant="control"
aria-label={ariaLabel}
icon={cameraOff ? <UnmuteCameraIcon /> : <MuteCameraIcon />}
onClick={toggleCamera}
/>
</Tooltip>
<Menu placement="top">
{({ isOpen }) => (
<>
<MenuButton
position="absolute"
top="0"
right="0"
as={IconButton}
variant="controlMenu"
aria-label="Options"
icon={<ChevronIcon />}
zIndex={100}
minWidth="20px"
{...(isOpen && { transform: "rotate(180deg)" })}
/>
<MenuList
background="#383838"
border="1px solid #323232"
color="#CCCCCC"
padding="5px 10px"
>
<Text userSelect="none" paddingX="12px" paddingY="6px">
CAMERA
</Text>
{cameraDevices.map((device) => {
return (
<MenuItem
key={device.deviceId}
onClick={() => selectCameraDevice(device.deviceId)}
>
<Flex alignItems="center">
{device.label}
{cameraDeviceId === device.deviceId && (
<Box marginLeft="10px">
<AiOutlineCheck />
</Box>
)}
</Flex>
</MenuItem>
);
})}
</MenuList>
</>
)}
</Menu>
</ButtonGroup>
);
}
================================================
FILE: components/controls/buttons/ChatButton.tsx
================================================
import React, { useContext } from "react";
import { IconButton, Tooltip } from "@chakra-ui/react";
import ChatIcon from "../../icons/ChatIcon";
import ChatContext from "context/Chat";
import styled from "@emotion/styled";
interface Props {
isActive: boolean;
hasNotifications: boolean;
}
const UnreadCircle = styled.div`
position: absolute;
width: 10px;
height: 10px;
border-radius: 5px;
background-color: #fa50b5;
top: calc(50% - 17.5px);
right: calc(50% - 17.5px);
`;
export default function ChatButton(): JSX.Element {
const { numUnreadMessages, isChatOpen, openChat, closeChat } =
useContext(ChatContext);
return (
<Tooltip label={isChatOpen ? "Close chat" : "Open chat"}>
<IconButton
variant="control"
aria-label="Toggle chat"
icon={
<>
<ChatIcon />
{numUnreadMessages > 0 && <UnreadCircle />}
</>
}
{...(isChatOpen && {
background: "#3E4247",
border: "1px solid #FFFFFF",
})}
onClick={isChatOpen ? closeChat : openChat}
/>
</Tooltip>
);
}
================================================
FILE: components/controls/buttons/MicrophoneButton.tsx
================================================
import React, { useCallback, useEffect, useState } from "react";
import {
Box,
Flex,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
Tooltip,
Text,
ButtonGroup,
} from "@chakra-ui/react";
import { useHotkeys } from "react-hotkeys-hook";
import { AiOutlineCheck } from "react-icons/ai";
import UserContext from "context/User";
import { useSpace } from "hooks/useSpace";
import { useUserMedia } from "hooks/useUserMedia";
import ChevronIcon from "components/icons/ChevronIcon";
import MuteMicrophoneIcon from "components/icons/MuteMicrophoneIcon";
import UnmuteMicrophoneIcon from "components/icons/UnmuteMicrophoneIcon";
export default function MicrophoneButton(): JSX.Element {
const {
userWantsMicMuted,
setUserWantsMicMuted,
microphoneDeviceId,
setMicrophoneDeviceId,
} = React.useContext(UserContext);
const {
microphoneDevices,
muteActiveMicrophone,
unMuteActiveMicrophone,
getActiveMicrophoneLevel,
} = useUserMedia();
const { isJoined, publishMicrophone } = useSpace();
const [temporaryUnmute, setTemporaryUnmute] = useState(false);
const [mouseOver, setMouseOver] = useState(false);
const [micLevelPercent, setMicLevelPercent] = useState(0);
const [micDistorted, setMicDistorted] = useState(false);
const requestRef = React.useRef<number>();
const selectAudioDevice = useCallback(
async (deviceId: string) => {
setMicrophoneDeviceId(deviceId);
if (isJoined) {
publishMicrophone(deviceId);
}
},
[isJoined, setMicrophoneDeviceId, publishMicrophone]
);
const toggleMicrophone = useCallback(() => {
if (userWantsMicMuted) {
setUserWantsMicMuted(false);
unMuteActiveMicrophone();
} else {
setUserWantsMicMuted(true);
muteActiveMicrophone();
}
}, [
userWantsMicMuted,
setUserWantsMicMuted,
muteActiveMicrophone,
unMuteActiveMicrophone,
]);
const animateMeter = useCallback(() => {
if (requestRef.current != undefined && getActiveMicrophoneLevel) {
let levels = getActiveMicrophoneLevel();
if (levels) {
// Convert to 0-100 scale
let scaled = (Math.max(-60, levels.avgDb) + 60) * 1.66;
setMicLevelPercent(scaled);
if (levels.peakDb > -0.5) {
setMicDistorted(true);
} else {
setMicDistorted(false);
}
} else {
setMicLevelPercent(0);
setMicDistorted(false);
}
}
requestRef.current = requestAnimationFrame(animateMeter);
}, [getActiveMicrophoneLevel]);
useEffect(() => {
requestRef.current = requestAnimationFrame(animateMeter);
return () => {
if (requestRef.current) {
cancelAnimationFrame(requestRef.current);
}
};
}, [animateMeter]);
const hotkeyText = "⌘ + d"; // adding this here to make it easy to change later. appears in tooltip on button.
useHotkeys(
"meta+d",
() => {
toggleMicrophone();
},
{ preventDefault: true },
[toggleMicrophone]
);
useHotkeys(
"space",
() => {
if (userWantsMicMuted && !temporaryUnmute) {
unMuteActiveMicrophone();
setTemporaryUnmute(true);
}
},
{ keydown: true, preventDefault: true },
[unMuteActiveMicrophone, userWantsMicMuted, temporaryUnmute]
);
useHotkeys(
"space",
() => {
if (temporaryUnmute) {
setTemporaryUnmute(false);
if (userWantsMicMuted) {
muteActiveMicrophone();
}
}
},
{ keyup: true, preventDefault: true },
[muteActiveMicrophone, userWantsMicMuted, temporaryUnmute]
);
const ariaLabel = userWantsMicMuted ? "Unmute" : "Mute";
return (
<ButtonGroup position="relative">
<Tooltip
label={
userWantsMicMuted ? `Unmute (${hotkeyText})` : `Mute (${hotkeyText})`
}
>
<IconButton
variant="control"
aria-label={ariaLabel}
onMouseEnter={() => setMouseOver(true)}
onMouseLeave={() => setMouseOver(false)}
icon={
userWantsMicMuted && !temporaryUnmute ? (
<MuteMicrophoneIcon />
) : (
<UnmuteMicrophoneIcon />
)
}
onClick={toggleMicrophone}
{...(isJoined &&
(micDistorted
? {
background: `radial-gradient(50% 50% at 50% 50%, rgba(255, 92, 56, 0.75) 0%, rgba(255, 92, 56, 0) ${micLevelPercent}%)`,
}
: {
background: `radial-gradient(50% 50% at 50% 50%, rgba(27, 227, 73, 0.75) 0%, rgba(27, 227, 73, 0) ${micLevelPercent}%)`,
}))}
/>
</Tooltip>
<Menu placement="top">
{({ isOpen }) => (
<>
<MenuButton
position="absolute"
top="0px"
right="0px"
zIndex={100}
as={IconButton}
aria-label="Options"
icon={<ChevronIcon />}
variant="controlMenu"
minWidth="20px"
{...(isOpen && { transform: "rotate(180deg)" })}
/>
<MenuList
background="#383838"
border="1px solid #323232"
color="#CCCCCC"
padding="5px 10px"
>
<Text userSelect="none" paddingX="12px" paddingY="6px">
MICROPHONE
</Text>
{microphoneDevices.map((device: MediaDeviceInfo) => {
return (
<MenuItem
key={device.deviceId}
onClick={() => selectAudioDevice(device.deviceId)}
>
<Flex alignItems="center">
{device.label}
{microphoneDeviceId == device.deviceId && (
<Box marginLeft="10px">
<AiOutlineCheck />
</Box>
)}
</Flex>
</MenuItem>
);
})}
</MenuList>
</>
)}
</Menu>
;
</ButtonGroup>
);
}
================================================
FILE: components/controls/buttons/ScreenShareButton.tsx
================================================
import React from "react";
import { IconButton, Tooltip } from "@chakra-ui/react";
import { useSpace } from "hooks/useSpace";
import ScreenShareIcon from "../../icons/ScreenShareIcon";
export default function ScreenShareButton(): JSX.Element {
const {
isLocalScreenShare,
isScreenShareActive,
startScreenShare,
stopScreenShare,
} = useSpace();
return (
<Tooltip
label={
isScreenShareActive
? isLocalScreenShare
? "Stop screen-share"
: "Someone else is currently screen-sharing"
: "Share Screen"
}
>
<IconButton
variant="control"
aria-label="Share Screen"
isDisabled={isScreenShareActive && !isLocalScreenShare}
icon={<ScreenShareIcon />}
{...(isLocalScreenShare && {
background: "#3E4247",
border: "1px solid #FFFFFF",
})}
onClick={isLocalScreenShare ? stopScreenShare : startScreenShare}
/>
</Tooltip>
);
}
================================================
FILE: components/controls/buttons/SendButton.tsx
================================================
import React, { useContext } from "react";
import { IconButton, Tooltip } from "@chakra-ui/react";
import styled from "@emotion/styled";
import SendIcon from "components/icons/SendIcon";
import { transientOptions } from "lib/utils";
interface Props {
isButtonEnabled: boolean;
handleOnClick: () => void;
}
const StatefulSendButton = styled(IconButton, transientOptions)<{
$isButtonEnabled: boolean;
}>`
opacity: ${(props) => (props.$isButtonEnabled ? "1" : "0.25")};
user-select: none;
`;
export default function SendButton({
isButtonEnabled,
handleOnClick,
}: Props): JSX.Element {
return (
<StatefulSendButton
tabIndex={-1}
$isButtonEnabled={isButtonEnabled}
width="18px"
height="15px"
variant="link"
aria-label="Send message"
icon={<SendIcon />}
onClick={handleOnClick}
disabled={!isButtonEnabled}
/>
);
}
================================================
FILE: components/controls/buttons/SettingsButton.tsx
================================================
import React from "react";
import {
Box,
IconButton,
Menu,
MenuButton,
MenuItem,
MenuList,
useDisclosure,
useToast,
} from "@chakra-ui/react";
import { useRouter } from "next/router";
import { copyLinkToastConfig, ToastIds } from "shared/toastConfigs";
import { useSpace } from "hooks/useSpace";
import useWindowDimensions from "hooks/useWindowDimension";
import SettingsIcon from "components/icons/SettingsIcon";
import RenameParticipantModal from "components/modals/RenameParticipantModal";
interface Props {
onLeave: () => void;
}
export default function SettingsButton({ onLeave }: Props): JSX.Element {
const toast = useToast();
const router = useRouter();
const { width } = useWindowDimensions();
const { isLocalScreenShare } = useSpace();
const {
isOpen: isRenameModalOpen,
onOpen: onRenameModalOpen,
onClose: onRenameModalClose,
} = useDisclosure();
const smallWindowWidth = (width && width < 480) || false;
const shareLink = () => {
navigator.clipboard.writeText(
`${window.location.protocol}//${window.location.host}/space/${router.query["id"]}`
);
if (!toast.isActive(ToastIds.COPY_LINK_TOAST_ID)) {
toast(copyLinkToastConfig);
}
};
return (
<>
<RenameParticipantModal
isOpen={isRenameModalOpen}
onClose={onRenameModalClose}
/>
<Box>
<Menu placement="top">
<MenuButton
as={IconButton}
variant="control"
aria-label="Options"
icon={<SettingsIcon />}
/>
<MenuList
background="#383838"
border="1px solid #323232"
color="#CCCCCC"
padding="5px 10px"
width="200px"
>
<MenuItem disabled={isLocalScreenShare} onClick={onRenameModalOpen}>
Change Name
</MenuItem>
<MenuItem onClick={shareLink}>Copy Invite Link</MenuItem>
{smallWindowWidth && <MenuItem onClick={onLeave}>Leave</MenuItem>}
</MenuList>
</Menu>
</Box>
</>
);
}
================================================
FILE: components/icons/ChatIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import chat from "../../public/chat.svg";
export default function ChatIcon(): JSX.Element {
return (
<Image
priority
alt="toggle chat"
width={25}
height={25}
src={chat}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/ChevronIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import chevronUp from "../../public/chevronUp.svg";
export default function ChevronIcon(): JSX.Element {
return (
<Image priority alt="open menu" width={12} height={8} src={chevronUp} />
);
}
================================================
FILE: components/icons/ChevronLeftIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import chevronLeft from "../../public/chevronLeft.svg";
export default function ChevronLeftIcon(): JSX.Element {
return <Image alt="paginate left" width={7} height={12} src={chevronLeft} />;
}
================================================
FILE: components/icons/ChevronRightIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import chevronRight from "../../public/chevronRight.svg";
export default function ChevronRightIcon(): JSX.Element {
return (
<Image alt="paginate right" width={7} height={12} src={chevronRight} />
);
}
================================================
FILE: components/icons/LeaveIcon.tsx
================================================
import React from "react";
export default function LeaveIcon(): JSX.Element {
return (
<svg
width="17"
height="16"
viewBox="0 0 17 16"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.5 0C0.223858 0 0 0.223858 0 0.5V15.5C0 15.7761 0.223858 16 0.5 16H5.5C5.77614 16 6 15.7761 6 15.5C6 15.2239 5.77614 15 5.5 15H1V1H5.5C5.77614 1 6 0.776142 6 0.5C6 0.223858 5.77614 0 5.5 0H0.5ZM3.5 8C3.22386 8 3 8.22386 3 8.5C3 8.77614 3.22386 9 3.5 9H14.29L10.6336 12.6564C10.4383 12.8517 10.4383 13.1683 10.6336 13.3636C10.8288 13.5588 11.1454 13.5588 11.3407 13.3636L15.8201 8.88415C15.8805 8.83374 15.9289 8.76934 15.9602 8.69592C15.9931 8.62072 16.0067 8.53907 16.0009 8.45855C16.011 8.31876 15.9626 8.17552 15.8557 8.06864L11.3407 3.55355C11.1454 3.35829 10.8288 3.35829 10.6336 3.55355C10.4383 3.74882 10.4383 4.0654 10.6336 4.26066L14.3729 8H3.5Z"
/>
</svg>
);
}
================================================
FILE: components/icons/MuteCameraIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import cameraOnIcon from "../../public/cameraOn.svg";
export default function MuteCameraIcon(): JSX.Element {
return (
<Image
alt="mute camera"
width={25}
height={25}
src={cameraOnIcon}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/MuteMicrophoneIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import microphoneOffIcon from "../../public/microphoneOff.svg";
export default function MuteMicrophoneIcon(): JSX.Element {
return (
<Image
alt="mute mic"
width={25}
height={25}
src={microphoneOffIcon}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/ScreenShareIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import screenShare from "../../public/screen-share.svg";
export default function ScreenShareIcon(): JSX.Element {
return (
<Image
priority
alt="Screen share"
width={25}
height={25}
src={screenShare}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/SendIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import send from "../../public/send.svg";
export default function SendIcon(): JSX.Element {
return (
<Image
priority
alt="Send message"
draggable={false}
width={18}
height={15}
src={send}
style={{ width: "18px", height: "15px" }}
/>
);
}
================================================
FILE: components/icons/SettingsIcon.tsx
================================================
import Image from "next/image";
import elipsisIcon from "../../public/elipsis.svg";
export default function SettingsIcon() {
return (
<Image
priority
alt="settings"
width={25}
height={25}
src={elipsisIcon}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/UnmuteCameraIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import cameraOffIcon from "../../public/cameraOff.svg";
export default function UnmuteCameraIcon(): JSX.Element {
return (
<Image
alt="unmute camera"
width={25}
height={25}
src={cameraOffIcon}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/icons/UnmuteMicrophoneIcon.tsx
================================================
import React from "react";
import Image from "next/image";
import microphoneOnIcon from "../../public/microphoneOn.svg";
export default function UnmuteMicrophoneIcon(): JSX.Element {
return (
<Image
priority
alt="unmute mic"
width={25}
height={25}
src={microphoneOnIcon}
style={{ width: "25px", height: "25px" }}
/>
);
}
================================================
FILE: components/modals/ACRScoreDialog.tsx
================================================
import React, { useCallback, useRef, useState } from "react";
import {
useDisclosure,
Button,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
AlertDialogCloseButton,
Box,
useRadio,
useRadioGroup,
VStack,
RadioProps,
Divider,
} from "@chakra-ui/react";
import { AcrScore } from "@mux/spaces-web";
import { useSpace } from "hooks/useSpace";
function RadioCard(props: RadioProps) {
const { getInputProps, getCheckboxProps } = useRadio(props);
const input = getInputProps();
const checkbox = getCheckboxProps();
return (
<Box as="label" width="100%">
<input {...input} />
<Box
{...checkbox}
color="#242628"
fontSize="14px"
cursor="pointer"
borderWidth="1px"
borderRadius="5px"
borderColor="#B2BAC2"
_checked={{
background: "#3E4247",
color: "white",
borderColor: "#3E4247",
}}
_hover={
!props.isChecked
? {
borderColor: "#242628",
}
: {}
}
_focus={{
boxShadow: "none",
}}
px="20px"
py="10px"
>
{props.children}
</Box>
</Box>
);
}
type Props = Pick<ReturnType<typeof useDisclosure>, "isOpen" | "onClose">;
const options = ["Excellent", "Good", "Fair", "Poor", "Bad"];
export default function ACRScoreDialog({ isOpen, onClose }: Props) {
const cancelRef = useRef(null);
const { submitAcrScore } = useSpace();
const [acrScore, setAcrScore] = useState<string>();
const [submitting, setSubmitting] = useState(false);
const [closing, setClosing] = useState(false);
const { getRootProps, getRadioProps } = useRadioGroup({
name: "acr",
onChange: (nextValue) => setAcrScore(nextValue),
});
const handleSubmittingAcrScore = useCallback(async () => {
if (acrScore) {
setSubmitting(true);
const numericScore = AcrScore[acrScore as keyof typeof AcrScore];
try {
await submitAcrScore(numericScore);
} catch (e) {
console.error(e);
}
onClose();
}
}, [acrScore, submitAcrScore, onClose]);
const handleClose = useCallback(() => {
setClosing(true);
onClose();
}, [onClose]);
const group = getRootProps();
const disableSubmission = closing || !acrScore;
return (
<>
<AlertDialog
motionPreset="slideInBottom"
leastDestructiveRef={cancelRef}
onClose={handleClose}
isOpen={isOpen}
isCentered
>
<AlertDialogOverlay />
<AlertDialogContent borderRadius="0px">
<AlertDialogHeader
color="#242628"
fontSize="18px"
fontWeight="normal"
>
How was the call quality?
</AlertDialogHeader>
<AlertDialogCloseButton
color="#666666"
marginTop="6px"
marginRight="3px"
/>
<Divider color="#E8E8E8" opacity={1} />
<AlertDialogBody my="17px">
<VStack {...group} spacing="10px">
{options.map((value) => {
const radio = getRadioProps({ value });
return (
<RadioCard key={value} {...radio}>
{value}
</RadioCard>
);
})}
</VStack>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} variant="muxDefault" onClick={handleClose}>
Cancel
</Button>
<Button
variant="muxConfirmation"
isLoading={submitting}
isDisabled={disableSubmission}
onClick={handleSubmittingAcrScore}
marginLeft="10px"
>
Submit
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
================================================
FILE: components/modals/ErrorModal.tsx
================================================
import {
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/react";
interface Props {
title: string;
message: string;
isOpen: boolean;
onClose: () => void;
}
export default function ErrorModal({ title, message, isOpen, onClose }: Props) {
return (
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>{title}</ModalHeader>
<ModalCloseButton />
<ModalBody>{message}</ModalBody>
<ModalFooter>
<Button variant="ghost" colorScheme="blue" mr={3} onClick={onClose}>
Dismiss
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
================================================
FILE: components/modals/RenameParticipantModal.tsx
================================================
import React, {
useCallback,
useMemo,
useRef,
useState,
useContext,
} from "react";
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
Button,
useDisclosure,
FormControl,
FormHelperText,
Input,
Divider,
} from "@chakra-ui/react";
import UserContext from "context/User";
import SpaceContext from "context/Space";
type Props = Pick<ReturnType<typeof useDisclosure>, "isOpen" | "onClose"> & {};
export default function RenameParticipantModal({
isOpen,
onClose,
}: Props): JSX.Element {
const user = React.useContext(UserContext);
const nameInputRef = useRef<HTMLInputElement>(null);
const [participantName, setParticipantName] = useState(user.participantName);
const { setDisplayName } = useContext(SpaceContext);
const invalidParticipantName = useMemo(
() => !participantName,
[participantName]
);
const handleDisplayNameChanged = (event: { target: { value: string } }) => {
setParticipantName(event.target.value);
};
const handleClose = useCallback(() => {
setParticipantName(user.participantName);
onClose();
}, [onClose, setParticipantName, user]);
const submit = useCallback(async () => {
if (invalidParticipantName) return;
if (user.participantName !== participantName) {
user.setParticipantName(participantName);
await setDisplayName(participantName);
}
onClose();
}, [invalidParticipantName, onClose, participantName, setDisplayName, user]);
return (
<Modal onClose={handleClose} isOpen={isOpen} isCentered>
<ModalOverlay />
<ModalContent borderRadius="0px">
<ModalHeader color="#242628" fontSize="18px" fontWeight="normal">
{"What's your name?"}
</ModalHeader>
<ModalCloseButton color="#666666" marginTop="6px" marginRight="3px" />
<Divider color="#E8E8E8" opacity={1} />
<ModalBody my="17px">
<FormControl isInvalid={invalidParticipantName}>
<Input
maxLength={64}
ref={nameInputRef}
id="participant_id"
value={participantName}
onChange={handleDisplayNameChanged}
onKeyPress={(e) => {
if (e.key === "Enter") {
submit();
}
}}
/>
<FormHelperText hidden={!invalidParticipantName}>
This cannot be empty.
</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button variant="muxDefault" onClick={handleClose}>
Cancel
</Button>
<Button
variant="muxConfirmation"
type="submit"
marginLeft="10px"
onClick={submit}
isDisabled={invalidParticipantName}
>
Enter
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
================================================
FILE: components/renderers/AudioRenderer.tsx
================================================
import { useEffect, useRef } from "react";
interface AudioTrackProps {
attachFunc: (element: HTMLAudioElement) => void;
}
const AudioRenderer = ({ attachFunc }: AudioTrackProps) => {
const audioEl = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
const el = audioEl.current;
if (!el) return;
attachFunc(el);
}, [attachFunc]);
return <audio ref={audioEl} autoPlay />;
};
export default AudioRenderer;
================================================
FILE: components/renderers/ChatRenderer.tsx
================================================
import React, {
FormEvent,
KeyboardEvent,
UIEvent,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { Box, Textarea } from "@chakra-ui/react";
import styled from "@emotion/styled";
import ChatContext, { ChatMessage } from "context/Chat";
import SendButton from "components/controls/buttons/SendButton";
import { transientOptions } from "lib/utils";
const ChatContainer = styled(Box, transientOptions)<{ $show: boolean }>`
position: absolute;
top: 0px;
right: 0px;
width: 300px;
height: 100%;
background-color: #292929;
border-left: 1px solid #666666;
display: flex;
flex-direction: column;
transform: translateX(100%);
${(props) =>
props.$show && "transition: transform 100ms; transform: translateX(0%);"}
`;
const ChatMessageListContainer = styled(Box)`
flex-grow: 1;
overflow-y: scroll;
padding-top: 30px;
padding-bottom: 30px;
`;
const ChatMessageContainer = styled(Box, transientOptions)<{
$isLastMessage: boolean;
}>`
position: relative;
padding-left: 30px;
padding-right: 30px;
margin-bottom: ${(props) => (props.$isLastMessage ? "0px" : "10px")};
color: #f9f9f9;
font-size: 14px;
white-space: pre-line;
`;
const ChatInputContainer = styled(Box)`
position: relative;
width: 100%;
height: 70x;
padding: 5px;
border-top: 1px solid #666666;
`;
const ChatTextarea = styled(Textarea)`
height: 60px;
border: 1px solid #e8e8e8;
border-radius: 2px;
background-color: white;
padding-right: 40px;
resize: none;
`;
const SendButtonContainer = styled(Box)`
position: absolute;
bottom: 10px;
right: 10px;
z-index: 100;
`;
const Name = styled.span`
font-weight: bold;
`;
const Time = styled.span`
font-size: 12px;
color: #cccccc;
margin-left: 6px;
vertical-align: top;
`;
const Url = styled.a`
text-decoration: underline;
`;
interface ChatMessageProps {
isLast: boolean;
isConsecutive: boolean;
message: ChatMessage;
}
const ChatMessage = ({ message, isConsecutive, isLast }: ChatMessageProps) => {
const urlPositions = [];
const regexp = /\b(https?:\/\/\S*\b)/g;
let match;
while ((match = regexp.exec(message.content)) !== null) {
urlPositions.push({
start: match.index,
end: regexp.lastIndex,
});
}
let content;
if (urlPositions.length > 0) {
let oldContent = message.content;
content = [];
let currentIndex = 0;
for (const position of urlPositions) {
const { start, end } = position;
content.push(
<span key={`${currentIndex},${position.start}`}>
{oldContent.substring(currentIndex, position.start)}
</span>
);
content.push(
<Url
target="_blank"
href={oldContent.substring(start, end)}
key={`${start},${end}`}
>
{oldContent.substring(start, end)}
</Url>
);
currentIndex = end;
}
content.push(
<span key={currentIndex}>{oldContent.substring(currentIndex)}</span>
);
} else {
content = message.content;
}
return (
<ChatMessageContainer $isLastMessage={isLast}>
{!isConsecutive && (
<>
<Name>{message.name}</Name>
<Time>{message.time}</Time>
<br />
</>
)}
{content}
</ChatMessageContainer>
);
};
export default function ChatRenderer({ show }: { show: boolean }): JSX.Element {
const { canSendMessage, sendChatMessage, chatMessages } =
useContext(ChatContext);
const [input, setInput] = useState("");
const messageListRef = useRef<HTMLDivElement>(null);
const isScrollNearBottomRef = useRef<boolean>(true);
const handleOnChange = useCallback((e: FormEvent<HTMLTextAreaElement>) => {
const { value } = e.currentTarget;
if (value === "\n" || value.endsWith("\n\n\n") || value.endsWith("\n ")) {
return;
}
setInput(value);
}, []);
const handleSubmit = useCallback(async () => {
const message = input.trim();
if (canSendMessage && input !== "") {
setInput("");
try {
await sendChatMessage(message);
} catch (error) {}
}
}, [input, canSendMessage, sendChatMessage]);
const handleOnKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.code === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
const handleScroll = useCallback((e: UIEvent<HTMLDivElement>) => {
const el = e.currentTarget;
const isNearBottom =
100 > el.scrollHeight - (el.offsetHeight + el.scrollTop);
isScrollNearBottomRef.current = isNearBottom;
}, []);
const renderMessages = useCallback(() => {
return chatMessages.map((message, index) => (
<ChatMessage
key={message.id}
message={message}
isConsecutive={
index !== 0 &&
message.connectionId === chatMessages[index - 1].connectionId
}
isLast={chatMessages.length - 1 === index}
/>
));
}, [chatMessages]);
useEffect(() => {
const el = messageListRef.current;
if (!el || chatMessages.length === 0) return;
const isLocalMessage = "state" in chatMessages[chatMessages.length - 1];
if (isScrollNearBottomRef.current || isLocalMessage) {
el.scrollTop = el.scrollHeight;
}
}, [chatMessages]);
return (
<ChatContainer $show={show}>
<ChatMessageListContainer ref={messageListRef} onScroll={handleScroll}>
{renderMessages()}
</ChatMessageListContainer>
<ChatInputContainer>
<ChatTextarea
placeholder="Send a message"
draggable={false}
onChange={handleOnChange}
value={input}
onKeyDown={handleOnKeyDown}
/>
{show && (
<SendButtonContainer>
<SendButton
handleOnClick={handleSubmit}
isButtonEnabled={canSendMessage && input !== ""}
/>
</SendButtonContainer>
)}
</ChatInputContainer>
</ChatContainer>
);
}
================================================
FILE: components/renderers/ScreenShareRenderer.tsx
================================================
import React, { useEffect, useRef } from "react";
import { Flex } from "@chakra-ui/react";
interface Props {
attach: (element: HTMLVideoElement) => void;
}
export default function ScreenShareRenderer({ attach }: Props): JSX.Element {
const videoEl = useRef<HTMLVideoElement | null>(null);
useEffect(() => {
const el = videoEl.current;
if (!el) return;
attach(el);
}, [attach]);
return (
<Flex
height="100%"
justifyContent="center"
maxHeight="100%"
maxWidth="100%"
position="relative"
>
<video
style={{ height: "100%", margin: "0px auto" }}
ref={videoEl}
autoPlay
playsInline
/>
</Flex>
);
}
================================================
FILE: components/renderers/VideoRenderer.tsx
================================================
import React, { useEffect, useRef, useState } from "react";
import poster from "../../public/poster.jpg";
import posterFlipped from "../../public/poster-flipped.jpg";
interface Props {
width: number;
height: number;
local: boolean;
connectionId: string;
attachFunc: (element: HTMLVideoElement) => void;
}
export default function VideoRenderer({
width,
height,
local,
connectionId,
attachFunc,
}: Props): JSX.Element {
const videoEl = useRef<HTMLVideoElement | null>(null);
const [disableFlip, setDisableFlip] = useState(false);
const handleEnterPiP = () => {
setDisableFlip(true);
};
const handleLeavePiP = () => {
setDisableFlip(false);
};
useEffect(() => {
const el = videoEl.current;
if (!el) return;
attachFunc(el);
el.addEventListener("enterpictureinpicture", handleEnterPiP);
el.addEventListener("leavepictureinpicture", handleLeavePiP);
return () => {
el.removeEventListener("enterpictureinpicture", handleEnterPiP);
el.removeEventListener("leavepictureinpicture", handleLeavePiP);
};
}, [attachFunc]);
return (
<video
id={connectionId}
poster={!disableFlip && local ? posterFlipped.src : poster.src}
ref={videoEl}
autoPlay
playsInline
style={{
width: "100%",
height: "100%",
objectFit: height > width ? "contain" : "cover",
transform: !disableFlip && local ? "scaleX(-1)" : "",
}}
/>
);
}
================================================
FILE: context/Chat.tsx
================================================
import { CustomEvent, SpaceEvent } from "@mux/spaces-web";
import { useSpaceEvent } from "hooks/useSpaceEvent";
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import SpaceContext from "./Space";
import { v4 } from "uuid";
import moment from "moment";
interface ChatMessagePayloadValue {
connectionId: string;
name: string;
id: string;
content: string;
}
interface RemoteChatMessage extends ChatMessagePayloadValue {
time: string;
}
interface LocalChatMessage extends RemoteChatMessage {
state: SendState;
}
enum SendState {
Pending,
Succeeded,
Failed,
}
export type ChatMessage = RemoteChatMessage | LocalChatMessage;
interface IChatContext {
isChatOpen: boolean;
openChat: () => void;
closeChat: () => void;
chatMessages: ChatMessage[];
canSendMessage: boolean;
numUnreadMessages: number;
sendChatMessage: (content: string) => Promise<void>;
}
export const ChatContext = createContext({} as IChatContext);
export default ChatContext;
type Props = {
children: ReactNode;
};
export const ChatProvider: React.FC<Props> = ({ children }) => {
const { publishCustomEvent, localParticipant } = useContext(SpaceContext);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const [numUnreadMessages, setNumUnreadMessages] = useState(0);
const [canSendMessage, setCanSendMessage] = useState(!!localParticipant);
const sendChatMessage = useCallback(
async (content: string) => {
if (!localParticipant) {
throw new Error(
"Cannot send chat message without having joined the space"
);
} else if (!canSendMessage) {
return;
}
const id = v4();
const payload = {
type: "chat",
value: {
connectionId: localParticipant.connectionId,
name: localParticipant.displayName || localParticipant.id,
id,
content,
},
};
const time = moment().format("hh:mm A");
let state = SendState.Pending;
const localChatMessage = { ...payload.value, id, state, time };
setChatMessages([...chatMessages, localChatMessage]);
try {
setCanSendMessage(false);
await publishCustomEvent(JSON.stringify(payload));
state = SendState.Succeeded;
} catch (error) {
state = SendState.Failed;
throw error;
} finally {
const messageIndex = chatMessages.findIndex(
(message) => message.id === id
);
if (messageIndex !== -1) {
const message = chatMessages[messageIndex];
if ("state" in message) {
message.state = state;
}
setChatMessages([
...chatMessages.slice(0, messageIndex),
{ ...message },
...chatMessages.slice(messageIndex + 1),
]);
}
}
},
[localParticipant, chatMessages, publishCustomEvent, canSendMessage]
);
useEffect(() => {
if (localParticipant && !canSendMessage) {
const timeout = setTimeout(() => {
setCanSendMessage(true);
}, 1000);
return () => {
clearTimeout(timeout);
};
}
}, [localParticipant, canSendMessage]);
useSpaceEvent(
SpaceEvent.ParticipantCustomEventPublished,
useCallback(
(participant, customEvent: CustomEvent) => {
const payload: { type: string; value: ChatMessagePayloadValue } =
JSON.parse(customEvent.payload);
if (participant !== localParticipant && payload.type === "chat") {
setChatMessages([
...chatMessages,
{ ...payload.value, time: moment().format("hh:mm A") },
]);
if (!isChatOpen) {
setNumUnreadMessages(numUnreadMessages + 1);
}
}
},
[chatMessages, localParticipant, isChatOpen, numUnreadMessages]
)
);
const openChat = useCallback(() => {
setIsChatOpen(true);
setNumUnreadMessages(0);
}, []);
const closeChat = useCallback(() => {
setIsChatOpen(false);
}, []);
return (
<ChatContext.Provider
value={{
isChatOpen,
openChat,
closeChat,
chatMessages,
canSendMessage,
numUnreadMessages,
sendChatMessage,
}}
>
{children}
</ChatContext.Provider>
);
};
================================================
FILE: context/Space.tsx
================================================
import React, {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import { useRouter } from "next/router";
import {
AcrScore,
ActiveSpeaker,
CustomEvent,
getDisplayMedia,
LocalParticipant,
LocalTrack,
RemoteParticipant,
Space,
SpaceEvent,
SpaceOptionsParams,
Track,
TrackSource,
} from "@mux/spaces-web";
import { MAX_PARTICIPANTS_PER_PAGE } from "lib/constants";
import UserContext from "./User";
import UserMediaContext from "./UserMedia";
interface SpaceState {
space: Space | null;
localParticipant: LocalParticipant | null;
remoteParticipants: RemoteParticipant[];
joinSpace: (
jwt: string,
endsAt?: number,
displayName?: string
) => Promise<void>;
joinError: string | null;
isJoined: boolean;
connectionIds: string[];
isBroadcasting: boolean;
participantCount: number;
publishCamera: (deviceId: string) => void;
publishMicrophone: (deviceId: string) => void;
unPublishDevice: (deviceId: string) => void;
isLocalScreenShareSupported: boolean;
isScreenShareActive: boolean;
isLocalScreenShare: boolean;
screenShareError: string | null;
attachScreenShare: (element: HTMLVideoElement) => void;
startScreenShare: () => void;
stopScreenShare: () => void;
screenShareParticipantConnectionId?: string;
screenShareParticipantName?: string;
spaceEndsAt: number | null;
leaveSpace: () => void;
submitAcrScore: (score: AcrScore) => Promise<void> | undefined;
setDisplayName: (name: string) => Promise<LocalParticipant | undefined>;
publishCustomEvent: (payload: string) => Promise<CustomEvent | undefined>;
}
export const SpaceContext = createContext({} as SpaceState);
export default SpaceContext;
type Props = {
children: ReactNode;
};
export const SpaceProvider: React.FC<Props> = ({ children }) => {
const { userWantsMicMuted, microphoneDeviceId, cameraOff, cameraDeviceId } =
useContext(UserContext);
const { getMicrophone, getCamera } = useContext(UserMediaContext);
const [space, setSpace] = useState<Space | null>(null);
const [spaceEndsAt, setSpaceEndsAt] = useState<number | null>(null);
const [localParticipant, setLocalParticipant] =
useState<LocalParticipant | null>(null);
const [remoteParticipants, setRemoteParticipants] = useState<
RemoteParticipant[]
>([]);
const [isJoined, setIsJoined] = useState(false);
const [joinError, setJoinError] = useState<string | null>(null);
const [isBroadcasting, setIsBroadcasting] = useState(false);
const [isLocalScreenShareSupported, setIsLocalScreenShareSupported] =
useState(
typeof window !== "undefined"
? !!navigator?.mediaDevices?.getDisplayMedia
: false
);
const [screenShareTrack, setScreenShareTrack] = useState<Track>();
const [screenShareError, setScreenShareError] = useState<string | null>(null);
const [participantScreenSharing, setParticipantScreenSharing] = useState<
LocalParticipant | RemoteParticipant | null
>(null);
const screenShareParticipantName = useMemo(() => {
return participantScreenSharing?.displayName;
}, [participantScreenSharing]);
const screenShareParticipantConnectionId = useMemo(() => {
return participantScreenSharing?.connectionId;
}, [participantScreenSharing]);
useEffect(() => {
setIsLocalScreenShareSupported(!!navigator.mediaDevices.getDisplayMedia);
}, []);
const isScreenShareActive = useMemo(() => {
return !!screenShareTrack;
}, [screenShareTrack]);
const isLocalScreenShare = useMemo(() => {
return participantScreenSharing instanceof LocalParticipant;
}, [participantScreenSharing]);
const participantCount = useMemo(() => {
return (localParticipant ? 1 : 0) + remoteParticipants.length;
}, [localParticipant, remoteParticipants]);
const connectionIds = useMemo(() => {
return (localParticipant ? [localParticipant.connectionId] : []).concat(
remoteParticipants.map((p) => p.connectionId)
);
}, [localParticipant, remoteParticipants]);
const publishForLocalParticipant = useCallback(
async (localParticipant: LocalParticipant) => {
const tracksToPublish = [];
if (cameraDeviceId && !cameraOff) {
const cameraTrack = await getCamera(cameraDeviceId);
if (cameraTrack) {
tracksToPublish.push(cameraTrack);
}
}
if (microphoneDeviceId) {
const microphoneTrack = await getMicrophone(microphoneDeviceId);
if (microphoneTrack) {
tracksToPublish.push(microphoneTrack);
}
}
if (tracksToPublish.length > 0) {
const publishedTracks = await localParticipant.publishTracks(
tracksToPublish
);
const publishedMicrophone = publishedTracks.find(
(track) => track.source === TrackSource.Microphone
);
if (publishedMicrophone && userWantsMicMuted) {
publishedMicrophone.mute();
}
}
},
[
cameraOff,
getMicrophone,
microphoneDeviceId,
getCamera,
cameraDeviceId,
userWantsMicMuted,
]
);
const router = useRouter();
const joinSpace = useCallback(
async (jwt: string, endsAt?: number, displayName?: string) => {
let _space: Space;
try {
let spaceOpts: SpaceOptionsParams = { displayName };
if (router.isReady && typeof router.query.auto_sub_limit === "string") {
spaceOpts.automaticParticipantLimit = parseInt(
router.query.auto_sub_limit
);
}
_space = new Space(jwt, spaceOpts);
} catch (e: any) {
setJoinError(e.message);
return;
}
if (endsAt) {
setSpaceEndsAt(endsAt);
}
const handleBroadcastStateChange = (broadcastState: boolean) => {
setIsBroadcasting(broadcastState);
};
const handleParticipantJoined = (newParticipant: RemoteParticipant) => {
setRemoteParticipants((oldParticipantArray) => {
const found = oldParticipantArray.find(
(p) => p.connectionId === newParticipant.connectionId
);
if (!found) {
return [...oldParticipantArray, newParticipant];
}
return oldParticipantArray;
});
};
const handleParticipantLeft = (participantLeaving: RemoteParticipant) => {
setRemoteParticipants((oldParticipantArray) =>
oldParticipantArray.filter(
(p) => p.connectionId !== participantLeaving.connectionId
)
);
};
const handleActiveSpeakerChanged = (
activeSpeakerChanges: ActiveSpeaker[]
) => {
setRemoteParticipants((oldParticipantArray) => {
const updatedParticipants = [...oldParticipantArray];
activeSpeakerChanges.forEach((activeSpeaker: ActiveSpeaker) => {
if (activeSpeaker.participant instanceof RemoteParticipant) {
const participantIndex = updatedParticipants.findIndex(
(p) => p.connectionId === activeSpeaker.participant.connectionId
);
if (participantIndex >= MAX_PARTICIPANTS_PER_PAGE - 1) {
updatedParticipants.splice(participantIndex, 1);
updatedParticipants.unshift(activeSpeaker.participant);
}
}
});
return updatedParticipants;
});
};
const setupScreenShare = (
participant: LocalParticipant | RemoteParticipant,
track: Track
) => {
setScreenShareTrack(track);
setParticipantScreenSharing(participant);
};
const tearDownScreenShare = () => {
setScreenShareTrack(undefined);
setParticipantScreenSharing(null);
};
const handleParticipantTrackPublished = (
participant: LocalParticipant | RemoteParticipant,
track: Track
) => {
if (track.source === TrackSource.Screenshare && track.hasMedia()) {
setupScreenShare(participant, track);
}
};
const handleParticipantTrackSubscribed = (
participant: LocalParticipant | RemoteParticipant,
track: Track
) => {
if (participant instanceof RemoteParticipant) {
reorderRemoteParticipantsBySubscription(participant);
}
if (track.source === TrackSource.Screenshare && track.hasMedia()) {
setupScreenShare(participant, track);
}
};
const handleParticipantTrackUnpublished = (
_participant: LocalParticipant | RemoteParticipant,
track: Track
) => {
if (track.source === TrackSource.Screenshare) {
tearDownScreenShare();
}
};
const handleParticipantTrackUnsubscribed = (
participant: LocalParticipant | RemoteParticipant,
track: Track
) => {
if (participant instanceof RemoteParticipant) {
reorderRemoteParticipantsBySubscription(participant);
}
if (track.source === TrackSource.Screenshare) {
tearDownScreenShare();
}
};
const handleParticipantDisplayNameChanged = (
updated: LocalParticipant | RemoteParticipant
) => {
if (updated instanceof RemoteParticipant) {
setRemoteParticipants((oldParticipantArray) => {
const found = oldParticipantArray.find(
(p) => p.connectionId === updated.connectionId
);
if (found) {
found.displayName = updated.displayName;
}
return oldParticipantArray;
});
// no participant found for this update
} else {
setLocalParticipant((local) => {
if (!local) {
return null;
}
local.displayName = updated.displayName;
return local;
});
}
};
const reorderRemoteParticipantsBySubscription = (
participantWhoChanged: RemoteParticipant
) => {
setRemoteParticipants((oldParticipantArray) => {
const updatedSubscriptionParticipants = oldParticipantArray.map(
(oldParticipant) =>
oldParticipant.connectionId === participantWhoChanged.connectionId
? participantWhoChanged
: oldParticipant
);
return [
...updatedSubscriptionParticipants.filter((p) => p.isSubscribed()),
...updatedSubscriptionParticipants.filter((p) => !p.isSubscribed()),
];
});
};
_space.on(SpaceEvent.ParticipantJoined, handleParticipantJoined);
_space.on(SpaceEvent.ParticipantLeft, handleParticipantLeft);
_space.on(SpaceEvent.ActiveSpeakersChanged, handleActiveSpeakerChanged);
_space.on(SpaceEvent.BroadcastStateChanged, handleBroadcastStateChange);
_space.on(
SpaceEvent.ParticipantTrackPublished,
handleParticipantTrackPublished
);
_space.on(
SpaceEvent.ParticipantTrackSubscribed,
handleParticipantTrackSubscribed
);
_space.on(
SpaceEvent.ParticipantTrackUnpublished,
handleParticipantTrackUnpublished
);
_space.on(
SpaceEvent.ParticipantTrackUnsubscribed,
handleParticipantTrackUnsubscribed
);
_space.on(
SpaceEvent.ParticipantDisplayNameChanged,
handleParticipantDisplayNameChanged
);
setSpace(_space);
try {
const _localParticipant = await _space.join();
publishForLocalParticipant(_localParticipant);
setLocalParticipant(_localParticipant);
setIsBroadcasting(_space.broadcasting);
setIsJoined(true);
} catch (error: any) {
setJoinError(error.message);
setIsBroadcasting(false);
setIsJoined(false);
}
},
[publishForLocalParticipant, router.isReady, router.query.auto_sub_limit]
);
const publishMicrophone = useCallback(
async (deviceId: string) => {
if (!localParticipant) {
throw new Error("Join a space before publishing a device.");
}
if (microphoneDeviceId !== deviceId) {
const microphoneTrack = await getMicrophone(deviceId);
localParticipant.updateTracks([microphoneTrack]);
} else {
const publishedMicrophone = localParticipant
.getAudioTracks()
.filter((track) => track.source === TrackSource.Microphone)
.find((track) => track.deviceId === deviceId);
if (publishedMicrophone) {
throw new Error("That microphone is already published.");
}
const microphoneTrack = await getMicrophone(deviceId);
await localParticipant.publishTracks([microphoneTrack]);
if (userWantsMicMuted) {
microphoneTrack.mute();
}
}
},
[localParticipant, microphoneDeviceId, getMicrophone, userWantsMicMuted]
);
const publishCamera = useCallback(
async (deviceId: string) => {
if (!localParticipant) {
throw new Error("Join a space before publishing a device.");
}
if (cameraDeviceId !== deviceId) {
const cameraTrack = await getCamera(deviceId);
localParticipant.updateTracks([cameraTrack]);
} else {
const publishedCamera = localParticipant
.getVideoTracks()
.filter((track) => track.source === TrackSource.Camera)
.find((track) => track.deviceId === deviceId);
if (publishedCamera) {
throw new Error("That camera is already published.");
}
const cameraTrack = await getCamera(deviceId);
localParticipant.publishTracks([cameraTrack]);
}
},
[localParticipant, cameraDeviceId, getCamera]
);
const unPublishDevice = useCallback(
(deviceId: string): void => {
if (!localParticipant) {
throw new Error(
"Join a space and publish a device before un-publishing the device."
);
}
const publishedDevice = localParticipant
.getTracks()
.find((track) => track.deviceId === deviceId);
if (publishedDevice) {
localParticipant.unpublishTracks([publishedDevice]);
} else {
throw new Error("Device to un-published was not found.");
}
},
[localParticipant]
);
const startScreenShare = useCallback(async () => {
try {
const screenStreams = await getDisplayMedia({
video: true,
audio: false,
});
const screenStream = screenStreams?.find(
(track) => track.source === "screenshare"
);
if (screenStream) {
if (localParticipant) {
return localParticipant
.publishTracks([screenStream])
.then((publishedTracks: LocalTrack[]) => {
if (publishedTracks.length < 1) {
throw new Error("Failed to publish track.");
}
return publishedTracks[0];
});
} else {
throw new Error("Join a space before starting a screen share.");
}
}
} catch (error) {
if (error instanceof Error) {
if (error.message === "Permission denied") {
// do nothing, they pressed cancel
} else if (error.message === "Permission denied by system") {
// chrome does not have permission to screen share
setScreenShareError(error.message);
} else {
// unhandled exception
console.error(error);
}
}
}
}, [localParticipant, setScreenShareError]);
const stopScreenShare = useCallback(async () => {
if (screenShareTrack && screenShareTrack instanceof LocalTrack) {
if (localParticipant) {
localParticipant.unpublishTracks([screenShareTrack]);
} else {
throw new Error("Join a space before stopping the screen share.");
}
} else {
throw new Error("No screen share to stop.");
}
}, [localParticipant, screenShareTrack]);
const attachScreenShare = useCallback(
(element: HTMLVideoElement) => {
screenShareTrack?.attachedElements.forEach((attachedEl) =>
screenShareTrack.detach(attachedEl)
);
screenShareTrack?.attach(element);
},
[screenShareTrack]
);
const submitAcrScore = useCallback(
(score: AcrScore) => {
if (!space) {
throw new Error(
"You must join a space before submitting an ACR score."
);
}
try {
return space.submitAcrScore(score);
} catch (error) {
throw new Error(`Error when submitting ACR score: ${error}`);
}
},
[space]
);
const leaveSpace = useCallback(() => {
try {
space?.removeAllListeners();
space?.leave();
} finally {
setJoinError(null);
setRemoteParticipants([]);
setIsBroadcasting(false);
setLocalParticipant(null);
setIsJoined(false);
setSpaceEndsAt(null);
// Don't call setSpace(null) here, as things like ACR Score submission depend on it
}
}, [space]);
const publishCustomEvent = useCallback(
async (payload: string) => {
try {
return await space?.localParticipant?.publishCustomEvent(payload);
} catch (error) {
throw error;
}
},
[space]
);
const setDisplayName = useCallback(
async (name: string) => {
return await space?.localParticipant?.setDisplayName(name);
},
[space]
);
return (
<SpaceContext.Provider
value={{
space,
localParticipant,
remoteParticipants,
joinSpace,
joinError,
isJoined,
connectionIds,
isBroadcasting,
participantCount,
publishCamera,
publishMicrophone,
unPublishDevice,
isLocalScreenShareSupported,
isScreenShareActive,
isLocalScreenShare,
screenShareError,
attachScreenShare,
startScreenShare,
stopScreenShare,
screenShareParticipantConnectionId,
screenShareParticipantName,
leaveSpace,
submitAcrScore,
spaceEndsAt,
setDisplayName,
publishCustomEvent,
}}
>
{children}
</SpaceContext.Provider>
);
};
================================================
FILE: context/User.tsx
================================================
import React, { createContext, ReactNode, useCallback, useState } from "react";
import { v4 as uuidv4 } from "uuid";
import { useLocalStorage } from "../hooks/useLocalStorage";
interface UserState {
id: string;
participantName: string;
setParticipantName: (newName: string) => string;
interactionRequired: boolean;
setInteractionRequired: (requiresInteraction: boolean) => void;
userWantsMicMuted: boolean;
setUserWantsMicMuted: (mute: boolean) => void;
cameraOff: boolean;
setCameraOff: (mute: boolean) => void;
microphoneDeviceId: string;
setMicrophoneDeviceId: (deviceId: string) => void;
cameraDeviceId: string;
setCameraDeviceId: (deviceId: string) => void;
pinnedConnectionId: string;
setPinnedConnectionId: (newConnectionId: string) => void;
}
const UserContext = createContext({} as UserState);
export default UserContext;
interface Props {
children: ReactNode;
}
export const UserProvider = ({ children }: Props) => {
const [interactionRequired, setInteractionRequired] = useState(true);
const [participantName, setParticipantName] = useLocalStorage(
"participantName",
""
);
// This should never change unless we reload
const id = uuidv4();
const [userWantsMicMuted, setUserWantsMicMuted] = useState(false);
const [cameraOff, setCameraOff] = useState(false);
const [microphoneDeviceId, setMicrophoneDeviceId] = useLocalStorage(
"audioDeviceId",
""
);
const [cameraDeviceId, setCameraDeviceId] = useLocalStorage(
"videoDeviceId",
""
);
const [pinnedConnectionId, setPinnedConnectionId] = useState("");
const handleSetParticipantName = useCallback(
(name: string) => {
setParticipantName(name);
return name;
},
[setParticipantName]
);
return (
<UserContext.Provider
value={{
id,
participantName,
setParticipantName: handleSetParticipantName,
interactionRequired,
setInteractionRequired,
userWantsMicMuted,
setUserWantsMicMuted,
cameraOff,
setCameraOff,
microphoneDeviceId,
setMicrophoneDeviceId,
cameraDeviceId,
setCameraDeviceId,
pinnedConnectionId,
setPinnedConnectionId,
}}
>
{children}
</UserContext.Provider>
);
};
================================================
FILE: context/UserMedia.tsx
================================================
import React, {
createContext,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
CreateLocalMediaOptions,
getUserMedia,
LocalTrack,
TrackSource,
} from "@mux/spaces-web";
import UserContext from "./User";
import { defaultAudioConstraints } from "shared/defaults";
interface UserMediaState {
activeCamera?: LocalTrack;
activeMicrophone?: LocalTrack;
userMediaError?: string;
requestPermissionAndPopulateDevices: () => void;
requestPermissionAndStartDevices: (
microphoneDeviceId?: string,
cameraDeviceId?: string
) => Promise<void>;
getCamera: (deviceId: string) => Promise<LocalTrack>;
cameraDevices: MediaDeviceInfo[];
activeCameraId?: string;
stopActiveCamera: () => void;
changeActiveCamera: (deviceId: string) => Promise<void>;
getMicrophone: (deviceId: string) => Promise<LocalTrack>;
microphoneDevices: MediaDeviceInfo[];
activeMicrophoneId?: string;
muteActiveMicrophone: () => void;
unMuteActiveMicrophone: () => void;
changeActiveMicrophone: (deviceId: string) => Promise<void>;
getActiveMicrophoneLevel: () => {
avgDb: number;
peakDb: number;
} | null;
}
export const UserMediaContext = createContext({} as UserMediaState);
export default UserMediaContext;
const defaultCameraOption: CreateLocalMediaOptions = {
video: {},
};
const defaultMicrophoneOption: CreateLocalMediaOptions = {
audio: { constraints: defaultAudioConstraints },
};
const noCameraOption: CreateLocalMediaOptions = {
video: false,
};
const noMicrophoneOption: CreateLocalMediaOptions = {
audio: false,
};
const defaultMicrophoneCameraOptions: CreateLocalMediaOptions = {
...defaultCameraOption,
...defaultMicrophoneOption,
};
type Props = {
children: ReactNode;
};
export const UserMediaProvider: React.FC<Props> = ({ children }) => {
const {
cameraDeviceId,
setCameraDeviceId,
microphoneDeviceId,
setMicrophoneDeviceId,
userWantsMicMuted,
} = React.useContext(UserContext);
const [microphoneDevices, setMicrophoneDevices] = useState<InputDeviceInfo[]>(
[]
);
const [activeMicrophone, setActiveMicrophone] = useState<LocalTrack>();
const [cameraDevices, setCameraDevices] = useState<InputDeviceInfo[]>([]);
const [activeCamera, setActiveCamera] = useState<LocalTrack>();
const [localAudioAnalyser, setLocalAudioAnalyser] = useState<AnalyserNode>();
const [userMediaError, setUserMediaError] = useState<string>();
const activeCameraId = useMemo(() => {
return activeCamera?.deviceId;
}, [activeCamera]);
const activeMicrophoneId = useMemo(() => {
return activeMicrophone?.deviceId;
}, [activeMicrophone]);
const setupLocalMicrophoneAnalyser = useCallback((track: LocalTrack) => {
let stream = new MediaStream([track.track]);
const audioCtx = new AudioContext();
const streamSource = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
streamSource.connect(analyser);
analyser.fftSize = 2048;
setLocalAudioAnalyser(analyser);
}, []);
const loadDevices = useCallback(async () => {
const availableDevices = await navigator.mediaDevices.enumerateDevices();
const audioInputDevices = availableDevices.filter(
(device) => device.kind === "audioinput"
);
setMicrophoneDevices(audioInputDevices);
const videoInputDevices = availableDevices.filter(
(device) => device.kind === "videoinput"
);
setCameraDevices(videoInputDevices);
}, []);
const requestPermissionAndPopulateDevices = useCallback(async () => {
let tracks: LocalTrack[] = [];
try {
tracks = await getUserMedia({
audio: { constraints: { deviceId: microphoneDeviceId } }, // loose constraint, will fail back if missing
video: { constraints: { deviceId: cameraDeviceId } }, // loose constraint, will fail back if missing
});
} catch (e) {
console.log("Failed to request default devices from your browser.");
}
try {
tracks.forEach((track) => {
if (track.deviceId) {
if (track.source === TrackSource.Camera) {
setCameraDeviceId(track.deviceId);
} else if (track.source === TrackSource.Microphone) {
setMicrophoneDeviceId(track.deviceId);
}
}
});
} catch (e) {
console.log("Error thrown while stopping devices.");
}
await loadDevices();
// Need to wait to stop the tracks until we've gotten the device list, or they'll have no labels
tracks.forEach((track) => track.track.stop());
}, [
cameraDeviceId,
loadDevices,
microphoneDeviceId,
setCameraDeviceId,
setMicrophoneDeviceId,
]);
const requestPermissionAndStartDevices = useCallback(
async (microphoneDeviceId?: string, cameraDeviceId?: string) => {
let options = { ...defaultMicrophoneCameraOptions };
if (typeof microphoneDeviceId === "undefined") {
options["audio"] = false;
} else if (microphoneDeviceId !== "") {
options["audio"] = {
constraints: {
deviceId: { exact: microphoneDeviceId },
...defaultAudioConstraints,
},
};
}
if (typeof cameraDeviceId === "undefined") {
options["video"] = false;
} else if (cameraDeviceId !== "") {
options["video"] = {
constraints: {
deviceId: { exact: cameraDeviceId },
},
};
}
let tracks: LocalTrack[] = [];
try {
tracks = await getUserMedia(options);
} catch (e: any) {
if (
e.name == "NotAllowedError" ||
e.name == "PermissionDeniedError" ||
e instanceof DOMException
) {
// permission denied to camera
setUserMediaError("NotAllowedError");
} else if (
e.name == "OverconstrainedError" ||
e.name == "ConstraintNotSatisfiedError"
) {
tracks = await getUserMedia({ audio: true, video: true });
} else {
setUserMediaError(e.name);
}
}
tracks.forEach((track) => {
switch (track.source) {
case TrackSource.Microphone:
setActiveMicrophone(track);
setupLocalMicrophoneAnalyser(track);
if (track.deviceId) {
setMicrophoneDeviceId(track.deviceId);
}
if (userWantsMicMuted) {
track.mute();
}
break;
case TrackSource.Camera:
setActiveCamera(track);
if (track.deviceId) {
setCameraDeviceId(track.deviceId);
}
break;
}
});
await loadDevices();
},
[
setupLocalMicrophoneAnalyser,
setMicrophoneDeviceId,
setCameraDeviceId,
userWantsMicMuted,
]
);
const muteActiveMicrophone = useCallback(() => {
try {
activeMicrophone?.mute();
} catch (error) {
throw new Error("Select an active microphone before muting.");
}
}, [activeMicrophone]);
const unMuteActiveMicrophone = useCallback(() => {
try {
activeMicrophone?.unMute();
} catch (error) {
throw new Error("Select an active microphone before muting.");
}
}, [activeMicrophone]);
const getMicrophone = useCallback(
async (deviceId: string) => {
let options = {
...defaultMicrophoneOption,
...noCameraOption,
};
if (deviceId !== "") {
options["audio"] = {
constraints: {
deviceId: { exact: deviceId },
...defaultAudioConstraints,
},
};
}
let tracks: LocalTrack[] = [];
try {
tracks = await getUserMedia(options);
} catch (e: any) {
// May occur if previously set device IDs are no longer available
if (
e.name == "NotAllowedError" ||
e.name == "PermissionDeniedError" ||
e instanceof DOMException
) {
// permission denied to camera
setUserMediaError("NotAllowedError");
} else if (
e.name == "OverconstrainedError" ||
e.name == "ConstraintNotSatisfiedError"
) {
setUserMediaError("OverconstrainedError");
} else {
setUserMediaError(e.name);
}
}
tracks.forEach((track) => {
switch (track.source) {
case TrackSource.Microphone:
setActiveMicrophone(track);
setupLocalMicrophoneAnalyser(track);
if (track.deviceId) {
setMicrophoneDeviceId(track.deviceId);
}
if (userWantsMicMuted) {
track.mute();
}
break;
}
});
return tracks[0];
},
[setupLocalMicrophoneAnalyser, setMicrophoneDeviceId, userWantsMicMuted]
);
const changeActiveMicrophone = useCallback(
async (deviceId: string) => {
await getMicrophone(deviceId);
},
[getMicrophone]
);
const getActiveMicrophoneLevel = useCallback(() => {
if (!localAudioAnalyser) {
return null;
}
const sampleBuffer = new Float32Array(localAudioAnalyser.fftSize);
localAudioAnalyser.getFloatTimeDomainData(sampleBuffer);
// Compute average power over the interval.
let sumOfSquares = 0;
for (let i = 0; i < sampleBuffer.length; i++) {
sumOfSquares += sampleBuffer[i] ** 2;
}
const avgPowerDecibels =
10 * Math.log10(sumOfSquares / sampleBuffer.length);
// Compute peak instantaneous power over the interval.
let peakInstantaneousPower = 0;
for (let i = 0; i < sampleBuffer.length; i++) {
const power = sampleBuffer[i] ** 2;
peakInstantaneousPower = Math.max(power, peakInstantaneousPower);
}
const peakInstantaneousPowerDecibels =
10 * Math.log10(peakInstantaneousPower);
return {
avgDb: avgPowerDecibels,
peakDb: peakInstantaneousPowerDecibels,
};
}, [localAudioAnalyser]);
const getCamera = useCallback(
async (deviceId: string) => {
let options = {
...defaultCameraOption,
...noMicrophoneOption,
};
if (deviceId !== "") {
options["video"] = {
constraints: {
deviceId: { exact: deviceId },
},
};
}
let tracks: LocalTrack[] = [];
try {
tracks = await getUserMedia(options);
} catch (e: any) {
// May occur if previously set device IDs are no longer available
if (
e.name == "NotAllowedError" ||
e.name == "PermissionDeniedError" ||
e instanceof DOMException
) {
// permission denied to camera
setUserMediaError("NotAllowedError");
} else if (
e.name == "OverconstrainedError" ||
e.name == "ConstraintNotSatisfiedError"
) {
setUserMediaError("OverconstrainedError");
} else {
setUserMediaError(e.name);
}
}
tracks.forEach((track) => {
switch (track.source) {
case TrackSource.Camera:
setActiveCamera(track);
if (track.deviceId) {
setCameraDeviceId(track.deviceId);
}
break;
}
});
return tracks[0];
},
[setCameraDeviceId]
);
const changeActiveCamera = useCallback(
async (deviceId: string) => {
await getCamera(deviceId);
},
[getCamera]
);
const stopActiveCamera = useCallback(() => {
if (activeCamera) {
activeCamera.stop();
setActiveCamera(undefined);
}
}, [activeCamera]);
const onDeviceChange = useCallback(async () => {
console.log("Detected device change, refreshing device list");
await loadDevices();
}, [loadDevices]);
useEffect(() => {
if (userWantsMicMuted && !activeMicrophone?.muted) {
activeMicrophone?.mute();
} else if (!userWantsMicMuted && activeMicrophone?.muted) {
activeMicrophone?.unMute();
}
}, [userWantsMicMuted, activeMicrophone]);
useEffect(() => {
navigator.mediaDevices.addEventListener("devicechange", onDeviceChange);
return () => {
navigator.mediaDevices.removeEventListener(
"devicechange",
onDeviceChange
);
};
}, [onDeviceChange]);
return (
<UserMediaContext.Provider
value={{
activeCamera,
activeMicrophone,
userMediaError,
requestPermissionAndPopulateDevices,
requestPermissionAndStartDevices,
getCamera,
cameraDevices,
activeCameraId,
stopActiveCamera,
changeActiveCamera,
getMicrophone,
microphoneDevices,
activeMicrophoneId,
muteActiveMicrophone,
unMuteActiveMicrophone,
changeActiveMicrophone,
getActiveMicrophoneLevel,
}}
>
{children}
</UserMediaContext.Provider>
);
};
================================================
FILE: hooks/useLocalStorage.tsx
================================================
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
useState,
} from "react";
declare global {
interface WindowEventMap {
"local-storage": CustomEvent;
}
}
type SetValue<T> = Dispatch<SetStateAction<T>>;
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, SetValue<T>] {
// Get from local storage then
// parse stored json or return initialValue
const readValue = useCallback((): T => {
// Prevent build error "window is undefined" but keep keep working
if (typeof window === "undefined") {
return initialValue;
}
try {
const item = window.localStorage.getItem(key);
const json = parseJSON(item) as T;
if (json === undefined) {
window.localStorage.removeItem(key);
}
return item ? json : initialValue;
} catch (error) {
console.warn(`Error reading localStorage key “${key}”:`, error);
return initialValue;
}
}, [initialValue, key]);
// State to store our value
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState<T>(readValue);
const setValueRef = useRef<SetValue<T>>();
setValueRef.current = (value) => {
// Prevent build error "window is undefined" but keeps working
if (typeof window == "undefined") {
console.warn(
`Tried setting localStorage key “${key}” even though environment is not a client`
);
}
try {
// Allow value to be a function so we have the same API as useState
const newValue = value instanceof Function ? value(storedValue) : value;
// Save state
setStoredValue(newValue);
// Save to local storage
window.localStorage.setItem(key, JSON.stringify(newValue));
// We dispatch a custom event so every useLocalStorage hook are notified
window.dispatchEvent(new Event("local-storage"));
} catch (error) {
console.warn(`Error setting localStorage key “${key}”:`, error);
}
};
// Return a wrapped version of useState's setter function that ...
// ... persists the new value to localStorage.
const setValue: SetValue<T> = useCallback(
(value) => setValueRef.current?.(value),
[]
);
useEffect(() => {
setStoredValue(readValue());
}, []);
return [storedValue, setValue];
}
// A wrapper for "JSON.parse()"" to support "undefined" value
function parseJSON<T>(value: string | null): T | undefined | null {
if (!value) return null; // don't attempt to parse a null value
try {
return value === "undefined" ? undefined : JSON.parse(value ?? "");
} catch {
console.log("parsing error on", { value });
return undefined;
}
}
================================================
FILE: hooks/useParticipant.ts
================================================
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
LocalParticipant,
LocalTrack,
ParticipantEvent,
RemoteParticipant,
Track,
TrackSource,
} from "@mux/spaces-web";
import SpaceContext from "../context/Space";
import UserContext from "../context/User";
export interface Participant {
id: string;
isLocal: boolean;
isSpeaking: boolean;
hasMicTrack: boolean;
isMicTrackMuted: boolean;
isCameraOff: boolean;
cameraWidth: number;
cameraHeight: number;
displayName: string;
attachVideoElement: (element: HTMLVideoElement) => void;
attachAudioElement: (element: HTMLAudioElement) => void;
}
export function useParticipant(connectionId: string): Participant {
const { localParticipant, remoteParticipants } = useContext(SpaceContext);
let participant: LocalParticipant | RemoteParticipant | undefined =
remoteParticipants.find((p) => p.connectionId === connectionId);
if (
!participant &&
localParticipant &&
localParticipant.connectionId === connectionId
) {
participant = localParticipant;
}
if (typeof participant === "undefined") {
throw new Error(`No participant found with connectionId: ${connectionId}`);
}
const id = useMemo(() => (participant ? participant.id : ""), [participant]);
const isLocal = useMemo(
() => participant instanceof LocalParticipant,
[participant]
);
const [isSpeaking, setIsSpeaking] = useState(false);
const [cameraTrack, setCameraTrack] = useState<Track>();
const [microphoneTrack, setMicrophoneTrack] = useState<Track>();
const [isMicTrackMuted, setIsMicTrackMuted] = useState(false);
const [isCameraOff, setIsCameraOff] = useState(true);
const [displayName, setDisplayName] = useState(participant.displayName);
const { userWantsMicMuted } = React.useContext(UserContext);
const hasMicTrack = useMemo(() => {
return !!microphoneTrack;
}, [microphoneTrack]);
const cameraDimensions = useMemo(() => {
return { width: cameraTrack?.width || 0, height: cameraTrack?.height || 0 };
}, [cameraTrack]);
const attachVideoElement = useCallback(
(element: HTMLVideoElement) => {
cameraTrack?.attachedElements.forEach((attachedEl) =>
cameraTrack.detach(attachedEl)
);
cameraTrack?.attach(element);
},
[cameraTrack]
);
const attachAudioElement = useCallback(
(element: HTMLAudioElement) => {
microphoneTrack?.attachedElements.forEach((attachedEl) =>
microphoneTrack.detach(attachedEl)
);
microphoneTrack?.attach(element);
},
[microphoneTrack]
);
const handleTrackAdded = useCallback(
(track: Track) => {
if (track.hasMedia()) {
if (track.source === TrackSource.Camera) {
setCameraTrack(track);
setIsCameraOff(track.isMuted());
}
if (track.source === TrackSource.Microphone) {
setMicrophoneTrack(track);
if (isLocal && userWantsMicMuted) {
(track as LocalTrack).mute();
}
if (track.isMuted()) {
setIsMicTrackMuted(true);
}
}
}
},
[setCameraTrack, setMicrophoneTrack]
);
const handleSetDisplayName = useCallback(() => {
setDisplayName(participant ? participant.displayName : "");
}, [setDisplayName]);
const handleTrackRemoved = useCallback(
(track: Track) => {
if (track.source === TrackSource.Camera) {
setCameraTrack(undefined);
setIsCameraOff(true);
}
if (track.source === TrackSource.Microphone) {
setMicrophoneTrack(undefined);
}
},
[setCameraTrack, setMicrophoneTrack]
);
useEffect(() => {
if (isLocal && microphoneTrack instanceof LocalTrack) {
if (userWantsMicMuted && !microphoneTrack.muted) {
microphoneTrack.mute();
} else if (!userWantsMicMuted && microphoneTrack.muted) {
microphoneTrack.unMute();
}
}
}, [userWantsMicMuted, isLocal, microphoneTrack]);
useEffect(() => {
if (!participant) return;
const onMuted = (track: Track) => {
if (track.source == TrackSource.Microphone) {
setIsMicTrackMuted(true);
} else if (track.source == TrackSource.Camera) {
setIsCameraOff(true);
}
};
const onUnmuted = (track: Track) => {
if (track.source == TrackSource.Microphone) {
setIsMicTrackMuted(false);
} else if (track.source == TrackSource.Camera) {
setIsCameraOff(false);
}
};
const onSpeaking = () => {
setIsSpeaking(true);
};
const stoppedSpeaking = () => {
setIsSpeaking(false);
};
participant.on(ParticipantEvent.TrackMuted, onMuted);
participant.on(ParticipantEvent.TrackUnmuted, onUnmuted);
participant.on(ParticipantEvent.StartedSpeaking, onSpeaking);
participant.on(ParticipantEvent.StoppedSpeaking, stoppedSpeaking);
participant.on(ParticipantEvent.TrackPublished, handleTrackAdded);
participant.on(ParticipantEvent.TrackUnpublished, handleTrackRemoved);
participant.on(ParticipantEvent.TrackSubscribed, handleTrackAdded);
participant.on(ParticipantEvent.TrackUnsubscribed, handleTrackRemoved);
participant.on(ParticipantEvent.DisplayNameChanged, handleSetDisplayName);
participant.getAudioTracks().forEach((track) => {
handleTrackAdded(track);
});
participant.getVideoTracks().forEach((track) => {
handleTrackAdded(track);
});
return () => {
if (!participant) return;
participant.off(ParticipantEvent.TrackMuted, onMuted);
participant.off(ParticipantEvent.TrackUnmuted, onUnmuted);
participant.off(ParticipantEvent.StartedSpeaking, onSpeaking);
participant.off(ParticipantEvent.StoppedSpeaking, stoppedSpeaking);
participant.off(ParticipantEvent.TrackPublished, handleTrackAdded);
participant.off(ParticipantEvent.TrackUnpublished, handleTrackRemoved);
participant.off(ParticipantEvent.TrackSubscribed, handleTrackAdded);
participant.off(ParticipantEvent.TrackUnsubscribed, handleTrackRemoved);
participant.off(
ParticipantEvent.DisplayNameChanged,
handleSetDisplayName
);
};
}, [participant, handleTrackAdded, handleTrackRemoved]);
return {
id,
isLocal,
isSpeaking,
isCameraOff,
hasMicTrack,
isMicTrackMuted,
cameraWidth: cameraDimensions.width,
cameraHeight: cameraDimensions.height,
displayName,
attachVideoElement,
attachAudioElement,
};
}
================================================
FILE: hooks/useSpace.ts
================================================
import { useContext } from "react";
import { AcrScore } from "@mux/spaces-web";
import SpaceContext from "../context/Space";
interface Space {
joinSpace: (
jwt: string,
endsAt?: number,
displayName?: string
) => Promise<void>;
joinError: string | null;
isJoined: boolean;
connectionIds: string[];
localParticipantConnectionId?: string;
isBroadcasting: boolean;
participantCount: number;
publishCamera: (deviceId: string) => void;
publishMicrophone: (deviceId: string) => void;
unPublishDevice: (deviceId: string) => void;
isLocalScreenShareSupported: boolean;
isScreenShareActive: boolean;
isLocalScreenShare: boolean;
screenShareError: string | null;
attachScreenShare: (element: HTMLVideoElement) => void;
startScreenShare: () => void;
stopScreenShare: () => void;
screenShareParticipantConnectionId?: string;
screenShareParticipantName?: string;
spaceEndsAt: number | null;
leaveSpace: () => void;
submitAcrScore: (score: AcrScore) => Promise<void> | undefined;
}
export const useSpace = (): Space => {
const {
space,
joinSpace,
joinError,
isJoined,
connectionIds,
isBroadcasting,
participantCount,
publishCamera,
publishMicrophone,
unPublishDevice,
isLocalScreenShareSupported,
isScreenShareActive,
isLocalScreenShare,
screenShareError,
attachScreenShare,
startScreenShare,
stopScreenShare,
screenShareParticipantConnectionId,
screenShareParticipantName,
spaceEndsAt,
leaveSpace,
submitAcrScore,
} = useContext(SpaceContext);
return {
joinSpace,
joinError,
isJoined,
connectionIds,
localParticipantConnectionId: space?.localParticipant?.connectionId,
isBroadcasting,
participantCount,
publishCamera,
publishMicrophone,
unPublishDevice,
isLocalScreenShareSupported,
isScreenShareActive,
isLocalScreenShare,
screenShareError,
attachScreenShare,
startScreenShare,
stopScreenShare,
screenShareParticipantConnectionId,
screenShareParticipantName,
spaceEndsAt,
leaveSpace,
submitAcrScore,
};
};
================================================
FILE: hooks/useSpaceEvent.tsx
================================================
import { SpaceEvent } from "@mux/spaces-web";
import SpaceContext from "context/Space";
import { useContext, useEffect, useRef } from "react";
/**
* @param event
* @param callback called whenever the event is fired. Warning: The callback should be provided using the `useCallback` hook to avoid this hook being called at every re-render of the component.
* @param startListening if false, the callback will not be registered until this is set to true. Useful for registering callbacks conditionally.
*/
export const useSpaceEvent = (
event: SpaceEvent,
callback: (...args: any) => void,
startListening = true
): void => {
const { space } = useContext(SpaceContext);
useEffect(() => {
if (!startListening) {
return;
}
try {
space?.on(event, callback);
} catch (_error) {
throw new Error(
`Unable to register useSpaceEvent callback ${callback.name} as the Space does not exist yet.`
);
}
return () => {
space?.off(event, callback);
};
}, [event, callback, space, startListening]);
};
================================================
FILE: hooks/useUserMedia.ts
================================================
import { useContext } from "react";
import UserMediaContext from "../context/UserMedia";
interface UserMedia {
userMediaError?: string;
requestPermissionAndPopulateDevices: () => void;
requestPermissionAndStartDevices: (
microphoneDeviceId?: string,
cameraDeviceId?: string
) => Promise<void>;
cameraDevices: MediaDeviceInfo[];
activeCameraId?: string;
stopActiveCamera: () => void;
changeActiveCamera: (deviceId: string) => Promise<void>;
microphoneDevices: MediaDeviceInfo[];
activeMicrophoneId?: string;
muteActiveMicrophone: () => void;
unMuteActiveMicrophone: () => void;
changeActiveMicrophone: (deviceId: string) => Promise<void>;
getActiveMicrophoneLevel: () => {
avgDb: number;
peakDb: number;
} | null;
}
export function useUserMedia(): UserMedia {
const {
userMediaError,
requestPermissionAndPopulateDevices,
requestPermissionAndStartDevices,
cameraDevices,
activeCameraId,
stopActiveCamera,
changeActiveCamera,
microphoneDevices,
activeMicrophoneId,
muteActiveMicrophone,
unMuteActiveMicrophone,
changeActiveMicrophone,
getActiveMicrophoneLevel,
} = useContext(UserMediaContext);
return {
userMediaError,
requestPermissionAndPopulateDevices,
requestPermissionAndStartDevices,
cameraDevices,
activeCameraId,
stopActiveCamera,
changeActiveCamera,
microphoneDevices,
activeMicrophoneId,
muteActiveMicrophone,
unMuteActiveMicrophone,
changeActiveMicrophone,
getActiveMicrophoneLevel,
};
}
================================================
FILE: hooks/useWindowDimension.ts
================================================
import { useEffect, useState } from "react";
type WindowDimensions = {
width: number | undefined;
height: number | undefined;
};
const useWindowDimensions = (): WindowDimensions => {
const [windowDimensions, setWindowDimensions] = useState<WindowDimensions>({
width: undefined,
height: undefined,
});
useEffect(() => {
function handleResize(): void {
setWindowDimensions({
width: window.innerWidth,
height: window.innerHeight,
});
}
handleResize();
window.addEventListener("resize", handleResize);
return (): void => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowDimensions;
};
export default useWindowDimensions;
================================================
FILE: lib/constants.ts
================================================
export const MAX_PARTICIPANTS_PER_PAGE = 20;
export const TEMPORARY_SPACE_PASSTHROUGH = "Temporary Meet Space";
================================================
FILE: lib/gallery.ts
================================================
type LayoutDescription = {
area: number;
cols: number;
rows: number;
width: number;
height: number;
};
/**
* Calculate optimal layout (most area used) of a number of boxes within a larger frame.
* Given number of boxes, aspectRatio of those boxes, and spacing between them.
*
* Thanks to Anton Dosov for algorithm shown in this article:
* https://dev.to/antondosov/building-a-video-gallery-just-like-in-zoom-4mam
*
* @param frameWidth width of the space holding the boxes
* @param frameHeight height of the space holding the boxes
* @param boxCount number of boxes to place (all same aspect ratio)
* @param aspectRatio ratio of width to height of the boxes (usually 16/9)
* @param spacing amount of space (margin) between boxes to spread them out
* @returns A description of the optimal layout
*/
export function calcOptimalBoxes(
frameWidth: number,
frameHeight: number,
boxCount: number,
aspectRatio: number,
spacing: number
): LayoutDescription {
let bestLayout: LayoutDescription = {
area: 0,
cols: 0,
rows: 0,
width: 0,
height: 0,
};
// try each possible number of columns to find the one with the highest area (optimum use of space)
for (let cols = 1; cols <= boxCount; cols++) {
const rows = Math.ceil(boxCount / cols);
// pack the frames together by removing the spacing between them
const packedWidth = frameWidth - spacing * (cols - 1);
const packedHeight = frameHeight - spacing * (rows - 1);
const hScale = packedWidth / (cols * aspectRatio);
const vScale = packedHeight / rows;
let width;
let height;
if (hScale <= vScale) {
width = Math.floor(packedWidth / cols);
height = Math.floor(width / aspectRatio);
} else {
height = Math.floor(packedHeight / rows);
width = Math.floor(height * aspectRatio);
}
const area = width * height;
if (area > bestLayout.area) {
bestLayout = { area, width, height, rows, cols };
}
}
return bestLayout;
}
================================================
FILE: lib/theme.ts
================================================
import localFont from "@next/font/local";
import { extendTheme, defineStyle } from "@chakra-ui/react";
const akkuratFont = localFont({
src: "./Akkurat-Regular.woff2",
});
const baseMuxButtonStyles = {
height: "40px",
borderRadius: "20px",
padding: "10px 20px 10px 20px",
borderWidth: "1px",
fontSize: "14px",
};
const control = defineStyle({
background: "#0a0a0b",
width: "60px",
height: "60px",
border: "1px solid #3E4247",
borderRadius: "50%",
_hover: {
background: "#242628",
border: "1px solid #FFFFFF",
},
_active: {
background: "#3E4247",
border: "1px solid #FFFFFF",
},
});
const controlMenu = defineStyle({
background: "#707C89",
width: "20px",
height: "20px",
border: "1px solid #565E67",
borderRadius: "50%",
fontWeight: "bold",
_hover: {
background: "#3E4247",
border: "1px solid #FFFFFF",
},
_active: {
background: "#3E4247",
border: "1px solid #FFFFFF",
},
});
const muxDefault = defineStyle({
...baseMuxButtonStyles,
background: "#FFFFFF",
borderColor: "#808C99",
fontWeight: "normal",
_hover: {
borderColor: "#242628",
},
_active: {
background: "#F3F5F6",
borderColor: "#242628",
},
});
const muxConfirmation = defineStyle({
...baseMuxButtonStyles,
background: "#00AA3C",
borderColor: "#00802D",
color: "#FFFFFF",
_hover: {
background: "#00802D",
borderColor: "#005C20",
_disabled: {
fontWeight: "normal",
background: "#E5E8EB",
borderColor: "#E5E8EB",
color: "#707C89",
},
},
_active: {
background: "#005C20",
borderColor: "#003D16",
},
_disabled: {
fontWeight: "normal",
background: "#E5E8EB",
borderColor: "#E5E8EB",
color: "#707C89",
},
});
const muxDestructive = defineStyle({
...baseMuxButtonStyles,
background: "#FDA89B",
borderColor: "#F87B6D",
fontWeight: "bold",
_hover: {
borderColor: "#F85C54",
background: "#F87B6D",
},
_active: {
borderColor: "#EA3737",
background: "#F85C54",
},
});
export const theme = extendTheme({
fonts: {
heading: `${akkuratFont.style.fontFamily}, sans-serif`,
body: `${akkuratFont.style.fontFamily}, sans-serif`,
},
styles: {
global: () => ({
body: {
width: "100%",
height: "100%",
position: "fixed",
},
}),
},
colors: {
red: {
50: "#FFE0E3",
100: "#FFC0C6",
200: "#FF949E",
300: "#FF6877",
400: "#FB3C4E",
500: "#E22C3E",
600: "#B71928",
700: "#950D1A",
800: "#73040E",
},
green: {
50: "#E0FFFA",
100: "#BAF8EE",
200: "#82EDDC",
300: "#49DFC6",
400: "#1FC3A8",
500: "#17A089",
600: "#047F6B",
700: "#036353",
800: "#00473C",
},
blue: {
50: "#DBEFFF",
100: "#B5E0FF",
200: "#82CBFF",
300: "#4FB6FF",
400: "#1CA0FD",
500: "#0B85DB",
600: "#006DB9",
700: "#005997",
800: "#003C66",
},
purple: {
50: "#F3E0FF",
100: "#E7BDFF",
200: "#CF86F9",
300: "#BE52FA",
400: "#9620D8",
500: "#7A10B6",
600: "#600494",
700: "#490072",
800: "#330050",
},
orange: {
50: "#FB501D",
100: "#DF491E",
},
},
semanticTokens: {
colors: {
gradient: "linear-gradient(90deg, #fb3c4e 0%, #fb2491 100%)",
},
},
components: {
Menu: {
parts: ["item"],
baseStyle: {
item: {
background: "#383838",
_focus: {
background: "transparent",
},
_active: {
background: "rgba(0,0,0,0.5)",
},
},
},
},
Button: {
variants: {
control,
controlMenu,
muxDefault,
muxConfirmation,
muxDestructive,
},
baseStyle: { transition: "none" },
},
},
});
================================================
FILE: lib/utils.ts
================================================
import { CreateStyled } from "@emotion/styled";
export const transientOptions: Parameters<CreateStyled>[1] = {
shouldForwardProp: (propName: string) => !propName.startsWith("$"),
};
================================================
FILE: next-env.d.ts
================================================
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
================================================
FILE: next.config.js
================================================
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
================================================
FILE: package.json
================================================
{
"name": "@mux/mux-meet-nextjs",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"prettier": "prettier --write .",
"prepare": "husky install"
},
"dependencies": {
"@chakra-ui/react": "^2.6.1",
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mux/mux-node": "^7.3.0",
"@mux/mux-player-react": "^1.11.0",
"@mux/spaces-web": "1.2.0",
"@next/font": "^13.4.1",
"axios": "^1.4.0",
"framer-motion": "^8.5.5",
"http-status-codes": "^2.2.0",
"jsonwebtoken": "^9.0.0",
"loglevel": "^1.8.1",
"moment": "^2.29.4",
"next": "13.1.6",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hotkeys-hook": "^4.4.0",
"react-icons": "^4.8.0",
"react-query": "^3.39.3",
"sharp": "^0.31.3",
"use-sound": "^4.0.1",
"uuid": "^9.0.0"
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.2",
"@types/node": "18.11.18",
"@types/react": "18.0.27",
"@types/uuid": "^9.0.1",
"eslint": "8.33.0",
"eslint-config-next": "13.1.6",
"eslint-config-prettier": "^8.8.0",
"husky": "^8.0.3",
"lint-staged": "^13.2.2",
"prettier": "2.8.3",
"typescript": "4.9.5"
},
"lint-staged": {
"**/*": "prettier --write --ignore-unknown"
}
}
================================================
FILE: pages/_app.tsx
================================================
import type { AppProps } from "next/app";
import { ChakraProvider } from "@chakra-ui/react";
import { QueryClient, QueryClientProvider } from "react-query";
import { theme } from "lib/theme";
import { UserProvider } from "context/User";
import { SpaceProvider } from "context/Space";
import { UserMediaProvider } from "context/UserMedia";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
function MyApp({ Component, pageProps }: AppProps) {
return (
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={theme}>
<UserProvider>
<UserMediaProvider>
<SpaceProvider>
<Component {...pageProps} />
</SpaceProvider>
</UserMediaProvider>
</UserProvider>
</ChakraProvider>
</QueryClientProvider>
);
}
export default MyApp;
================================================
FILE: pages/_document.tsx
================================================
import { ColorModeScript } from "@chakra-ui/react";
import Document, { Html, Head, Main, NextScript } from "next/document";
export default class MyDocument extends Document {
render() {
return (
<Html lang="en">
<Head />
<body>
<ColorModeScript />
<Main />
<NextScript />
</body>
</Html>
);
}
}
================================================
FILE: pages/api/spaces/[id].ts
================================================
import { StatusCodes } from "http-status-codes";
import { NextApiRequest, NextApiResponse } from "next";
import { muxClient } from "server-lib/services";
import axios from "axios";
const fetchSpace = async (id: string) => {
let response;
try {
response = await muxClient.get(`/video/v1/spaces/${id}`);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Error: ${error.response?.status}`);
}
throw new Error("Unknown error");
}
return response.data.data;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "GET" && typeof req.query.id === "string") {
const space = await fetchSpace(req.query.id);
res.status(StatusCodes.OK).json(space);
} else {
res.status(StatusCodes.METHOD_NOT_ALLOWED);
}
}
export { fetchSpace };
================================================
FILE: pages/api/spaces.ts
================================================
import axios from "axios";
import { StatusCodes } from "http-status-codes";
import { TEMPORARY_SPACE_PASSTHROUGH } from "lib/constants";
import { NextApiRequest, NextApiResponse } from "next";
import { muxClient } from "../../server-lib/services";
type Space = {
id: string;
type: string;
created_at: string;
status: "active" | "idle";
passthrough?: string;
};
const fetchSpaces = async (): Promise<Space[]> => {
let response;
try {
response = await muxClient.get(`/video/v1/spaces?limit=100`);
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Error: ${error.response?.status}`);
}
throw new Error("Unknown error");
}
return response.data.data;
};
const createSpace = async () => {
let response;
try {
response = await muxClient.post(`/video/v1/spaces`, {
passthrough: TEMPORARY_SPACE_PASSTHROUGH,
});
} catch (error) {
if (axios.isAxiosError(error)) {
throw new Error(`Error: ${error.response?.status}`);
}
throw new Error("Unknown error");
}
return response.data.data;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method === "POST") {
const activeSpaceLimit = process.env.ACTIVE_SPACE_LIMIT;
if (activeSpaceLimit) {
const limit = parseInt(activeSpaceLimit, 10);
const spaces = await fetchSpaces();
const activeSpaces = spaces.filter(({ status }) => status === "active");
if (activeSpaces.length >= limit) {
return res.status(StatusCodes.INSUFFICIENT_SPACE_ON_RESOURCE).end();
}
}
try {
const space = await createSpace();
res.status(StatusCodes.OK).json(space);
} catch (error) {
const message = (error as Error).message as string;
if (message.includes("401")) {
res.status(StatusCodes.UNAUTHORIZED).end();
}
}
} else {
res.status(StatusCodes.METHOD_NOT_ALLOWED).end();
}
}
================================================
FILE: pages/api/token.ts
================================================
import { StatusCodes } from "http-status-codes";
import jwt from "jsonwebtoken";
import { NextApiRequest, NextApiResponse } from "next";
const { MUX_SIGNING_KEY, MUX_PRIVATE_KEY } = process.env;
type ResponseData = {
spaceJWT: string;
};
function signJWT(spaceId: string, participantId: string): ResponseData {
const JWT = jwt.sign(
{
kid: MUX_SIGNING_KEY ?? "",
aud: "rt",
sub: spaceId,
participant_id: participantId,
},
Buffer.from(MUX_PRIVATE_KEY ?? "", "base64"),
{ algorithm: "RS256", expiresIn: "1h" }
);
return { spaceJWT: JWT };
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>
) {
const {
body: { spaceId, participantId },
method,
} = req;
if (method === "POST") {
res
.status(StatusCodes.OK)
.json(signJWT(spaceId as string, participantId as string));
} else {
res.status(StatusCodes.METHOD_NOT_ALLOWED);
}
}
================================================
FILE: pages/api/webhooks.ts
================================================
import Mux from "@mux/mux-node";
import { StatusCodes } from "http-status-codes";
import { TEMPORARY_SPACE_PASSTHROUGH } from "lib/constants";
import { NextApiRequest, NextApiResponse } from "next";
import { muxClient } from "server-lib/services";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const headers = req.headers;
const muxSignature = headers["mux-signature"] as string;
const secret = process.env.WEBHOOK_SECRET;
if (!secret) {
console.error("WEBHOOK_SECRET not specified");
return res.status(StatusCodes.INTERNAL_SERVER_ERROR).end();
}
if (
req.method === "POST" &&
Mux.Webhooks.verifyHeader(JSON.stringify(req.body), muxSignature, secret)
) {
const { body } = req;
if (
body.type === "video.space.idle" &&
body.data.passthrough.startsWith(TEMPORARY_SPACE_PASSTHROUGH)
) {
try {
await muxClient.delete(`/video/v1/spaces/${body.data.id}`);
return res.status(StatusCodes.OK).end();
} catch (error) {
return res.status(StatusCodes.BAD_REQUEST).end();
}
}
return res.status(StatusCodes.OK).end();
}
return res.status(StatusCodes.METHOD_NOT_ALLOWED).end();
}
================================================
FILE: pages/index.tsx
================================================
import React, { useEffect, useMemo, useRef, useState } from "react";
import Head from "next/head";
import { useRouter } from "next/router";
import {
Button,
Box,
FormControl,
FormLabel,
Stack,
Heading,
Input,
FormHelperText,
Flex,
Center,
HStack,
useDisclosure,
} from "@chakra-ui/react";
import { useMutation } from "react-query";
import UserContext from "context/User";
import { useUserMedia } from "hooks/useUserMedia";
import Header from "components/Header";
import MicrophoneButton from "components/controls/buttons/MicrophoneButton";
import CameraButton from "components/controls/buttons/CameraButton";
import ErrorModal from "components/modals/ErrorModal";
const Home = () => {
const router = useRouter();
const didPopulateDevicesRef = useRef(false);
const user = React.useContext(UserContext);
const [participantName, setParticipantName] = useState("");
const [joining, setJoining] = useState(false);
const [hasBlurredNameInput, setHasBlurredNameInput] = useState(false);
const { requestPermissionAndPopulateDevices } = useUserMedia();
const [errorModalTitle, setErrorModalTitle] = useState("");
const [errorModalMessage, setErrorModalMessage] = useState("");
const {
isOpen: isErrorModalOpen,
onOpen: onErrorModalOpen,
onClose: onErrorModalClose,
} = useDisclosure();
const createSpaceMutation = useMutation(["Spaces"], () =>
fetch(`/api/spaces`, {
method: "POST",
mode: "no-cors",
}).then((res) => {
if (res.ok) {
return res.json();
} else if (res.status === 401) {
throw new Error("Not authorized to create space");
} else if (res.status === 419) {
throw new Error("Maximum active spaces reached");
} else {
throw new Error("Error creating space");
}
})
);
useEffect(() => {
if (didPopulateDevicesRef.current === false) {
didPopulateDevicesRef.current = true;
requestPermissionAndPopulateDevices();
}
}, [requestPermissionAndPopulateDevices]);
useEffect(() => {
setParticipantName(user.participantName);
}, [user.participantName]);
const invalidParticipantName = useMemo(
() => !participantName,
[participantName]
);
const disableJoin = useMemo(
() => invalidParticipantName,
[invalidParticipantName]
);
const isNameInputInvalid = useMemo(
() => invalidParticipantName && hasBlurredNameInput,
[invalidParticipantName, hasBlurredNameInput]
);
const handleParticipantNameChange = (event: {
target: { value: string };
}) => {
setParticipantName(event.target.value);
};
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setJoining(true);
user.setParticipantName!(participantName);
user.setInteractionRequired(false);
createSpaceMutation.mutate(undefined, {
onError: (error) => {
const message = (error as Error).message;
if (message.includes("Not authorized")) {
setErrorModalTitle("Not authorized to create a new space");
setErrorModalMessage(
"Make sure MUX_TOKEN_ID and MUX_TOKEN_SECRET are set. Refer to the README in https://github.com/muxinc/meet for more details."
);
onErrorModalOpen();
} else if (message.includes("Maximum active spaces reached")) {
setErrorModalTitle("Maximum active space limit reached");
setErrorModalMessage(
"There are too many active spaces being used. Please try again later."
);
onErrorModalOpen();
}
setJoining(false);
},
onSuccess: (newSpace) => {
if (newSpace) {
router.push({
pathname: `/space/${newSpace.id}`,
});
}
},
});
}
return (
<>
<Head>
<title>Mux Meet</title>
<meta name="description" content="Real-time meetings powered by Mux" />
<link rel="icon" href="/favicon.png" />
</Head>
<Flex direction="column" height="100vh" backgroundColor="#323232">
<Header />
<Center height="100%" zIndex={1}>
<Flex direction="column" align="center">
<Box background="white" padding="4" borderRadius="4" width="360px">
<form onSubmit={handleSubmit}>
<Stack spacing="4">
<Heading>Join a Space</Heading>
<FormControl
isInvalid={isNameInputInvalid}
onBlur={() => setHasBlurredNameInput(true)}
>
<FormLabel>Your Name</FormLabel>
<Input
maxLength={40}
id="participant_name"
value={participantName}
onChange={handleParticipantNameChange}
/>
<FormHelperText
color={!isNameInputInvalid ? "white" : "#E22C3E"}
>
This cannot be empty.
</FormHelperText>
</FormControl>
<Button
type="submit"
width="full"
isDisabled={disableJoin}
isLoading={joining}
>
Join a New Space
</Button>
</Stack>
</form>
</Box>
<HStack marginTop="1rem">
<MicrophoneButton />
<CameraButton />
</HStack>
</Flex>
</Center>
</Flex>
<ErrorModal
title={errorModalTitle}
message={errorModalMessage}
isOpen={isErrorModalOpen}
onClose={onErrorModalClose}
/>
</>
);
};
export default Home;
================================================
FILE: pages/space/[id].tsx
================================================
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "react-query";
import Head from "next/head";
import Image from "next/image";
import { useRouter } from "next/router";
import type { NextPage, GetServerSideProps } from "next";
import { Flex } from "@chakra-ui/react";
import styled from "@emotion/styled";
import moment from "moment";
import UserContext from "context/User";
import { useSpace } from "hooks/useSpace";
import { tokenPOST } from "client/token";
import { fetchSpace } from "pages/api/spaces/[id]";
import Stage from "components/Stage";
import UserInteractionPrompt from "components/UserInteractionPrompt";
import starfield from "../../public/starfield-bg.jpg";
import { TEMPORARY_SPACE_PASSTHROUGH } from "lib/constants";
const BackgroundImageWrap = styled.div`
position: fixed;
height: 100vh;
width: 100vw;
overflow: hidden;
z-index: 0;
`;
interface Props {
heliosURL: string;
spaceBackendURL: string;
title: string;
endsAt?: number;
}
const SpacePage: NextPage<Props> = ({
heliosURL,
spaceBackendURL,
title,
endsAt,
}: Props) => {
const router = useRouter();
const { id } = router.query;
const { isReady: isRouterReady } = router;
const user = React.useContext(UserContext);
const { joinSpace, leaveSpace } = useSpace();
const [canJoinSpace, setCanJoinSpace] = useState(true);
const participantNameRef = useRef<string>("");
useEffect(() => {
setCanJoinSpace((endsAt && moment(endsAt).diff(moment()) > 0) || !endsAt);
}, [endsAt]);
useEffect(() => {
if (spaceBackendURL) {
(window as any).MUX_SPACES_BACKEND_URL = spaceBackendURL;
}
if (heliosURL) {
(window as any).MUX_SPACES_HELIOS_URL = heliosURL;
}
}, [spaceBackendURL, heliosURL]);
const mutation = useMutation(tokenPOST, {
onSuccess: async (data) => {
await joinSpace(
data.spaceJWT,
endsAt,
participantNameRef.current || user.participantName
);
},
});
const authenticate = useCallback(
(spaceId: string, participantId: string) => {
mutation.mutate({
spaceId,
participantId,
});
},
[mutation]
);
const handleJoin = useCallback(() => {
if (typeof id === "string" && canJoinSpace) {
authenticate(id, `${participantNameRef.current}|${user.id}`);
}
}, [id, canJoinSpace, authenticate, user.id]);
useEffect(() => {
if (!isRouterReady) return;
if (!id || Array.isArray(id)) {
console.warn("No space selected");
return;
}
router.events.on("routeChangeStart", leaveSpace);
router.events.on("routeChangeComplete", handleJoin);
return () => {
router.events.off("routeChangeStart", leaveSpace);
router.events.off("routeChangeComplete", handleJoin);
};
}, [
id,
user,
router,
handleJoin,
leaveSpace,
isRouterReady,
user.participantName,
]);
return (
<>
<Head>
<title>{title}</title>
<meta name="description" content="This is your space room" />
<link rel="icon" href="/favicon.png" />
</Head>
<Flex height="100vh" overflow="hidden" direction="column">
<BackgroundImageWrap>
<Image
alt="Starfield"
src={starfield}
placeholder="blur"
quality={100}
fill
sizes="100vw"
style={{
objectFit: "cover",
}}
/>
</BackgroundImageWrap>
{/* required to handle auto play https://developer.chrome.com/blog/autoplay/ */}
{user.interactionRequired ? (
<UserInteractionPrompt
onInteraction={handleJoin}
participantNameRef={participantNameRef}
/>
) : (
<Stage />
)}
</Flex>
</>
);
};
const { MUX_SPACES_BACKEND_URL = "", MUX_SPACES_HELIOS_URL = "" } = process.env;
export const getServerSideProps: GetServerSideProps = async (context) => {
const { id } = context.query;
let passthrough;
let createdAt;
try {
if (typeof id === "string") {
({ passthrough, created_at: createdAt } = await fetchSpace(id));
}
} catch (error) {}
let props: Record<string, any> = {
heliosURL: MUX_SPACES_HELIOS_URL,
spaceBackendURL: MUX_SPACES_BACKEND_URL,
title: passthrough ? `${passthrough} | Mux Meet` : "Mux Meet Space",
};
if (
process.env.SPACE_DURATION_SECONDS &&
passthrough === TEMPORARY_SPACE_PASSTHROUGH &&
createdAt
) {
props.endsAt = moment(createdAt * 1000)
.add(process.env.SPACE_DURATION_SECONDS, "seconds")
.valueOf();
}
return {
props,
};
};
export default SpacePage;
================================================
FILE: server-lib/services.ts
================================================
import axios from "axios";
const {
MUX_TOKEN_ID,
MUX_TOKEN_SECRET,
MUX_API_HOST = "api.mux.com",
} = process.env;
const muxOptions = {
auth: { username: MUX_TOKEN_ID ?? "", password: MUX_TOKEN_SECRET ?? "" },
baseURL: `https://${MUX_API_HOST}`,
};
const muxClient = axios.create(muxOptions);
export { muxClient };
================================================
FILE: shared/defaults.tsx
================================================
export const defaultAudioConstraints: MediaTrackConstraints = {
autoGainControl: true,
echoCancellation: true,
noiseSuppression: true,
channelCount: 1,
};
================================================
FILE: shared/toastConfigs.tsx
================================================
import { Box } from "@chakra-ui/react";
import { UseToastOptions } from "@chakra-ui/react";
export enum ToastIds {
COPY_LINK_TOAST_ID,
SHARING_SCREEN_TOAST_ID,
BROADCASTING_SCREEN_TOAST_ID,
VIEWING_SHARED_SCREEN_TOAST_ID,
}
export const copyLinkToastConfig: UseToastOptions = {
id: ToastIds.COPY_LINK_TOAST_ID,
position: "top",
render: () => {
return (
<Box
color="#0a0a0b"
background="#cff1fc"
fontSize="14px"
padding="15px 50px"
textAlign="center"
>
Share link copied to your clipboard.
</Box>
);
},
};
export const sharingScreenToastConfig: UseToastOptions = {
id: ToastIds.SHARING_SCREEN_TOAST_ID,
position: "top",
duration: null,
};
export const viewingSharedScreenToastConfig: UseToastOptions = {
id: ToastIds.VIEWING_SHARED_SCREEN_TOAST_ID,
position: "top",
duration: null,
};
export const broadcastingToastConfig: UseToastOptions = {
id: ToastIds.BROADCASTING_SCREEN_TOAST_ID,
position: "top-left",
duration: null,
};
export const participantEventToastConfig: UseToastOptions = {
// Don't set id, as we expect to have multiple on screen at the same time
isClosable: true,
position: "top-right",
duration: 3000,
};
================================================
FILE: tsconfig.json
================================================
{
"compilerOptions": {
"baseUrl": ".",
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
gitextract_6okmy78h/ ├── .eslintrc.json ├── .gitignore ├── .husky/ │ └── pre-commit ├── .prettierignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── client/ │ └── token.ts ├── components/ │ ├── Controls.tsx │ ├── Gallery.tsx │ ├── GalleryLayout.tsx │ ├── Header.tsx │ ├── Meeting.tsx │ ├── MuteIndicator.tsx │ ├── Notifications.tsx │ ├── Participant.tsx │ ├── ParticipantAudio.tsx │ ├── ParticipantInfoBar.tsx │ ├── ParticipantName.tsx │ ├── Pin.tsx │ ├── Sounds.tsx │ ├── Stage.tsx │ ├── Timer.tsx │ ├── Toasts.tsx │ ├── UserInteractionPrompt.tsx │ ├── controls/ │ │ ├── ControlsCenter.tsx │ │ ├── ControlsLeft.tsx │ │ ├── ControlsRight.tsx │ │ └── buttons/ │ │ ├── CameraButton.tsx │ │ ├── ChatButton.tsx │ │ ├── MicrophoneButton.tsx │ │ ├── ScreenShareButton.tsx │ │ ├── SendButton.tsx │ │ └── SettingsButton.tsx │ ├── icons/ │ │ ├── ChatIcon.tsx │ │ ├── ChevronIcon.tsx │ │ ├── ChevronLeftIcon.tsx │ │ ├── ChevronRightIcon.tsx │ │ ├── LeaveIcon.tsx │ │ ├── MuteCameraIcon.tsx │ │ ├── MuteMicrophoneIcon.tsx │ │ ├── ScreenShareIcon.tsx │ │ ├── SendIcon.tsx │ │ ├── SettingsIcon.tsx │ │ ├── UnmuteCameraIcon.tsx │ │ └── UnmuteMicrophoneIcon.tsx │ ├── modals/ │ │ ├── ACRScoreDialog.tsx │ │ ├── ErrorModal.tsx │ │ └── RenameParticipantModal.tsx │ └── renderers/ │ ├── AudioRenderer.tsx │ ├── ChatRenderer.tsx │ ├── ScreenShareRenderer.tsx │ └── VideoRenderer.tsx ├── context/ │ ├── Chat.tsx │ ├── Space.tsx │ ├── User.tsx │ └── UserMedia.tsx ├── hooks/ │ ├── useLocalStorage.tsx │ ├── useParticipant.ts │ ├── useSpace.ts │ ├── useSpaceEvent.tsx │ ├── useUserMedia.ts │ └── useWindowDimension.ts ├── lib/ │ ├── constants.ts │ ├── gallery.ts │ ├── theme.ts │ └── utils.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages/ │ ├── _app.tsx │ ├── _document.tsx │ ├── api/ │ │ ├── spaces/ │ │ │ └── [id].ts │ │ ├── spaces.ts │ │ ├── token.ts │ │ └── webhooks.ts │ ├── index.tsx │ └── space/ │ └── [id].tsx ├── server-lib/ │ └── services.ts ├── shared/ │ ├── defaults.tsx │ └── toastConfigs.tsx └── tsconfig.json
SYMBOL INDEX (108 symbols across 65 files)
FILE: client/token.ts
type TokenParams (line 3) | interface TokenParams {
type TokenResponse (line 8) | interface TokenResponse {
FILE: components/Controls.tsx
function Controls (line 12) | function Controls(): JSX.Element {
FILE: components/Gallery.tsx
function pushToFront (line 14) | function pushToFront<T>(array: T[], element: T) {
type Props (line 22) | interface Props {
function Gallery (line 29) | function Gallery({
FILE: components/GalleryLayout.tsx
type Props (line 12) | interface Props {
FILE: components/Header.tsx
function Header (line 8) | function Header(): JSX.Element {
FILE: components/Meeting.tsx
function Meeting (line 16) | function Meeting(): JSX.Element {
FILE: components/MuteIndicator.tsx
type Props (line 5) | interface Props {
function MuteIndicator (line 10) | function MuteIndicator({
FILE: components/Notifications.tsx
function Notifications (line 11) | function Notifications(): JSX.Element {
FILE: components/Participant.tsx
type Props (line 12) | interface Props {
function Participant (line 18) | function Participant({
FILE: components/ParticipantAudio.tsx
type Props (line 7) | interface Props {
FILE: components/ParticipantInfoBar.tsx
type Props (line 6) | interface Props {
function ParticipantInfoBar (line 12) | function ParticipantInfoBar({
FILE: components/ParticipantName.tsx
type Props (line 4) | interface Props {
function ParticipantName (line 9) | function ParticipantName({ isSmall, children }: Props): JSX.Element {
FILE: components/Pin.tsx
type Props (line 7) | interface Props {
function Pin (line 11) | function Pin({ connectionId }: Props): JSX.Element {
FILE: components/Sounds.tsx
function Sounds (line 10) | function Sounds(): JSX.Element {
function JoinedSounds (line 16) | function JoinedSounds(): JSX.Element {
FILE: components/Stage.tsx
function Stage (line 22) | function Stage(): JSX.Element {
FILE: components/Toasts.tsx
function Toasts (line 24) | function Toasts(): JSX.Element {
FILE: components/UserInteractionPrompt.tsx
type Props (line 30) | interface Props {
function UserInteractionPrompt (line 35) | function UserInteractionPrompt({
FILE: components/controls/ControlsCenter.tsx
type Props (line 12) | interface Props {
function ControlsCenter (line 16) | function ControlsCenter({ onLeave }: Props): JSX.Element {
FILE: components/controls/ControlsLeft.tsx
function ControlsLeft (line 7) | function ControlsLeft(): JSX.Element {
FILE: components/controls/ControlsRight.tsx
type Props (line 8) | interface Props {
function ControlsRight (line 27) | function ControlsRight({ onLeave }: Props): JSX.Element {
FILE: components/controls/buttons/CameraButton.tsx
function CameraButton (line 25) | function CameraButton(): JSX.Element {
FILE: components/controls/buttons/ChatButton.tsx
type Props (line 8) | interface Props {
function ChatButton (line 23) | function ChatButton(): JSX.Element {
FILE: components/controls/buttons/MicrophoneButton.tsx
function MicrophoneButton (line 25) | function MicrophoneButton(): JSX.Element {
FILE: components/controls/buttons/ScreenShareButton.tsx
function ScreenShareButton (line 8) | function ScreenShareButton(): JSX.Element {
FILE: components/controls/buttons/SendButton.tsx
type Props (line 8) | interface Props {
function SendButton (line 20) | function SendButton({
FILE: components/controls/buttons/SettingsButton.tsx
type Props (line 21) | interface Props {
function SettingsButton (line 25) | function SettingsButton({ onLeave }: Props): JSX.Element {
FILE: components/icons/ChatIcon.tsx
function ChatIcon (line 6) | function ChatIcon(): JSX.Element {
FILE: components/icons/ChevronIcon.tsx
function ChevronIcon (line 6) | function ChevronIcon(): JSX.Element {
FILE: components/icons/ChevronLeftIcon.tsx
function ChevronLeftIcon (line 6) | function ChevronLeftIcon(): JSX.Element {
FILE: components/icons/ChevronRightIcon.tsx
function ChevronRightIcon (line 6) | function ChevronRightIcon(): JSX.Element {
FILE: components/icons/LeaveIcon.tsx
function LeaveIcon (line 3) | function LeaveIcon(): JSX.Element {
FILE: components/icons/MuteCameraIcon.tsx
function MuteCameraIcon (line 6) | function MuteCameraIcon(): JSX.Element {
FILE: components/icons/MuteMicrophoneIcon.tsx
function MuteMicrophoneIcon (line 6) | function MuteMicrophoneIcon(): JSX.Element {
FILE: components/icons/ScreenShareIcon.tsx
function ScreenShareIcon (line 6) | function ScreenShareIcon(): JSX.Element {
FILE: components/icons/SendIcon.tsx
function SendIcon (line 6) | function SendIcon(): JSX.Element {
FILE: components/icons/SettingsIcon.tsx
function SettingsIcon (line 5) | function SettingsIcon() {
FILE: components/icons/UnmuteCameraIcon.tsx
function UnmuteCameraIcon (line 6) | function UnmuteCameraIcon(): JSX.Element {
FILE: components/icons/UnmuteMicrophoneIcon.tsx
function UnmuteMicrophoneIcon (line 6) | function UnmuteMicrophoneIcon(): JSX.Element {
FILE: components/modals/ACRScoreDialog.tsx
function RadioCard (line 23) | function RadioCard(props: RadioProps) {
type Props (line 64) | type Props = Pick<ReturnType<typeof useDisclosure>, "isOpen" | "onClose">;
function ACRScoreDialog (line 68) | function ACRScoreDialog({ isOpen, onClose }: Props) {
FILE: components/modals/ErrorModal.tsx
type Props (line 12) | interface Props {
function ErrorModal (line 19) | function ErrorModal({ title, message, isOpen, onClose }: Props) {
FILE: components/modals/RenameParticipantModal.tsx
type Props (line 27) | type Props = Pick<ReturnType<typeof useDisclosure>, "isOpen" | "onClose"...
function RenameParticipantModal (line 29) | function RenameParticipantModal({
FILE: components/renderers/AudioRenderer.tsx
type AudioTrackProps (line 3) | interface AudioTrackProps {
FILE: components/renderers/ChatRenderer.tsx
type ChatMessageProps (line 91) | interface ChatMessageProps {
function ChatRenderer (line 155) | function ChatRenderer({ show }: { show: boolean }): JSX.Element {
FILE: components/renderers/ScreenShareRenderer.tsx
type Props (line 4) | interface Props {
function ScreenShareRenderer (line 8) | function ScreenShareRenderer({ attach }: Props): JSX.Element {
FILE: components/renderers/VideoRenderer.tsx
type Props (line 6) | interface Props {
function VideoRenderer (line 14) | function VideoRenderer({
FILE: context/Chat.tsx
type ChatMessagePayloadValue (line 15) | interface ChatMessagePayloadValue {
type RemoteChatMessage (line 22) | interface RemoteChatMessage extends ChatMessagePayloadValue {
type LocalChatMessage (line 26) | interface LocalChatMessage extends RemoteChatMessage {
type SendState (line 30) | enum SendState {
type ChatMessage (line 36) | type ChatMessage = RemoteChatMessage | LocalChatMessage;
type IChatContext (line 38) | interface IChatContext {
type Props (line 52) | type Props = {
FILE: context/Space.tsx
type SpaceState (line 31) | interface SpaceState {
type Props (line 73) | type Props = {
FILE: context/User.tsx
type UserState (line 6) | interface UserState {
type Props (line 28) | interface Props {
FILE: context/UserMedia.tsx
type UserMediaState (line 20) | interface UserMediaState {
type Props (line 74) | type Props = {
FILE: hooks/useLocalStorage.tsx
type WindowEventMap (line 11) | interface WindowEventMap {
type SetValue (line 16) | type SetValue<T> = Dispatch<SetStateAction<T>>;
function useLocalStorage (line 18) | function useLocalStorage<T>(
function parseJSON (line 90) | function parseJSON<T>(value: string | null): T | undefined | null {
FILE: hooks/useParticipant.ts
type Participant (line 20) | interface Participant {
function useParticipant (line 34) | function useParticipant(connectionId: string): Participant {
FILE: hooks/useSpace.ts
type Space (line 6) | interface Space {
FILE: hooks/useUserMedia.ts
type UserMedia (line 5) | interface UserMedia {
function useUserMedia (line 29) | function useUserMedia(): UserMedia {
FILE: hooks/useWindowDimension.ts
type WindowDimensions (line 3) | type WindowDimensions = {
function handleResize (line 14) | function handleResize(): void {
FILE: lib/constants.ts
constant MAX_PARTICIPANTS_PER_PAGE (line 1) | const MAX_PARTICIPANTS_PER_PAGE = 20;
constant TEMPORARY_SPACE_PASSTHROUGH (line 2) | const TEMPORARY_SPACE_PASSTHROUGH = "Temporary Meet Space";
FILE: lib/gallery.ts
type LayoutDescription (line 1) | type LayoutDescription = {
function calcOptimalBoxes (line 23) | function calcOptimalBoxes(
FILE: pages/_app.tsx
function MyApp (line 18) | function MyApp({ Component, pageProps }: AppProps) {
FILE: pages/_document.tsx
class MyDocument (line 4) | class MyDocument extends Document {
method render (line 5) | render() {
FILE: pages/api/spaces.ts
type Space (line 8) | type Space = {
function handler (line 50) | async function handler(
FILE: pages/api/spaces/[id].ts
function handler (line 22) | async function handler(
FILE: pages/api/token.ts
type ResponseData (line 7) | type ResponseData = {
function signJWT (line 11) | function signJWT(spaceId: string, participantId: string): ResponseData {
function handler (line 25) | function handler(
FILE: pages/api/webhooks.ts
function handler (line 8) | async function handler(
FILE: pages/index.tsx
function handleSubmit (line 93) | async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
FILE: pages/space/[id].tsx
type Props (line 30) | interface Props {
FILE: shared/toastConfigs.tsx
type ToastIds (line 4) | enum ToastIds {
Condensed preview — 82 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (172K chars).
[
{
"path": ".eslintrc.json",
"chars": 54,
"preview": "{\n \"extends\": [\"next/core-web-vitals\", \"prettier\"]\n}\n"
},
{
"path": ".gitignore",
"chars": 437,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": ".husky/pre-commit",
"chars": 58,
"preview": "#!/bin/sh\n. \"$(dirname \"$0\")/_/husky.sh\"\n\nnpx lint-staged\n"
},
{
"path": ".prettierignore",
"chars": 194,
"preview": "# dependencies\n/node_modules\n/.pnp\n.pnp.js\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n\n# production\n/build\n\n# debug\nn"
},
{
"path": ".prettierrc.json",
"chars": 3,
"preview": "{}\n"
},
{
"path": "LICENSE",
"chars": 1060,
"preview": "MIT License\n\nCopyright (c) 2023 Mux\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof thi"
},
{
"path": "README.md",
"chars": 5716,
"preview": "# Mux Meet\n\n> [!WARNING]\n> Mux Real-Time Video has been sunset and is unavailable for new usage. Existing access will en"
},
{
"path": "client/token.ts",
"chars": 305,
"preview": "import axios from \"axios\";\n\ninterface TokenParams {\n spaceId: string;\n participantId: string;\n}\n\ninterface TokenRespon"
},
{
"path": "components/Controls.tsx",
"chars": 1642,
"preview": "import React, { useCallback } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { Flex, useDisclosure } from"
},
{
"path": "components/Gallery.tsx",
"chars": 4808,
"preview": "import React, { useCallback, useMemo, useState } from \"react\";\nimport { IconButton, Center, Flex } from \"@chakra-ui/reac"
},
{
"path": "components/GalleryLayout.tsx",
"chars": 1247,
"preview": "import React, {\n Children,\n cloneElement,\n isValidElement,\n ReactNode,\n useMemo,\n} from \"react\";\nimport { Flex } fr"
},
{
"path": "components/Header.tsx",
"chars": 1112,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\nimport { AiFillGithub } from \"react-icons/ai\";\nimport { Flex,"
},
{
"path": "components/Meeting.tsx",
"chars": 2638,
"preview": "import React, { useContext } from \"react\";\nimport { Center, Flex } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"h"
},
{
"path": "components/MuteIndicator.tsx",
"chars": 1401,
"preview": "import React from \"react\";\nimport { Box, Flex, Icon } from \"@chakra-ui/react\";\nimport { IoMicOffOutline } from \"react-ic"
},
{
"path": "components/Notifications.tsx",
"chars": 2035,
"preview": "import { useState, useEffect } from \"react\";\nimport { useDisclosure } from \"@chakra-ui/react\";\n\nimport { useSpace } from"
},
{
"path": "components/Participant.tsx",
"chars": 1827,
"preview": "import React, { useMemo } from \"react\";\nimport { Box, Center, Flex } from \"@chakra-ui/react\";\n\nimport UserContext from \""
},
{
"path": "components/ParticipantAudio.tsx",
"chars": 429,
"preview": "import React from \"react\";\n\nimport { useParticipant } from \"hooks/useParticipant\";\n\nimport AudioRenderer from \"./rendere"
},
{
"path": "components/ParticipantInfoBar.tsx",
"chars": 1074,
"preview": "import React from \"react\";\nimport { Flex, Text } from \"@chakra-ui/react\";\n\nimport MuteIndicator from \"./MuteIndicator\";\n"
},
{
"path": "components/ParticipantName.tsx",
"chars": 630,
"preview": "import { memo } from \"react\";\nimport { Box, Center, Flex } from \"@chakra-ui/react\";\n\ninterface Props {\n isSmall: boolea"
},
{
"path": "components/Pin.tsx",
"chars": 1050,
"preview": "import React from \"react\";\nimport { IconButton } from \"@chakra-ui/react\";\nimport { MdPushPin, MdOutlinePushPin } from \"r"
},
{
"path": "components/Sounds.tsx",
"chars": 1074,
"preview": "import useSound from \"use-sound\";\nimport { SpaceEvent } from \"@mux/spaces-web\";\n\nimport { useSpaceEvent } from \"hooks/us"
},
{
"path": "components/Stage.tsx",
"chars": 815,
"preview": "import React from \"react\";\nimport { Center, Heading, Spinner } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks"
},
{
"path": "components/Timer.tsx",
"chars": 2173,
"preview": "import { useEffect, useState } from \"react\";\nimport { useRouter } from \"next/router\";\nimport { Tooltip } from \"@chakra-u"
},
{
"path": "components/Toasts.tsx",
"chars": 5146,
"preview": "import styled from \"@emotion/styled\";\nimport { useRouter } from \"next/router\";\nimport { useCallback, useEffect, useRef }"
},
{
"path": "components/UserInteractionPrompt.tsx",
"chars": 3761,
"preview": "import React, {\n useState,\n useEffect,\n useMemo,\n useRef,\n useContext,\n MutableRefObject,\n} from \"react\";\nimport I"
},
{
"path": "components/controls/ControlsCenter.tsx",
"chars": 920,
"preview": "import React from \"react\";\nimport { HStack } from \"@chakra-ui/react\";\n\nimport MicrophoneButton from \"./buttons/Microphon"
},
{
"path": "components/controls/ControlsLeft.tsx",
"chars": 619,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\nimport { Flex } from \"@chakra-ui/react\";\n\nimport muxLogo from"
},
{
"path": "components/controls/ControlsRight.tsx",
"chars": 1650,
"preview": "import React from \"react\";\nimport { Button, Flex, Text } from \"@chakra-ui/react\";\nimport LeaveIcon from \"components/icon"
},
{
"path": "components/controls/buttons/CameraButton.tsx",
"chars": 3774,
"preview": "import React, { useCallback } from \"react\";\nimport {\n Box,\n ButtonGroup,\n Flex,\n IconButton,\n Menu,\n MenuButton,\n "
},
{
"path": "components/controls/buttons/ChatButton.tsx",
"chars": 1118,
"preview": "import React, { useContext } from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport ChatIcon from"
},
{
"path": "components/controls/buttons/MicrophoneButton.tsx",
"chars": 6190,
"preview": "import React, { useCallback, useEffect, useState } from \"react\";\nimport {\n Box,\n Flex,\n IconButton,\n Menu,\n MenuBut"
},
{
"path": "components/controls/buttons/ScreenShareButton.tsx",
"chars": 1000,
"preview": "import React from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport { useSpace } from \"hooks/useS"
},
{
"path": "components/controls/buttons/SendButton.tsx",
"chars": 893,
"preview": "import React, { useContext } from \"react\";\nimport { IconButton, Tooltip } from \"@chakra-ui/react\";\n\nimport styled from \""
},
{
"path": "components/controls/buttons/SettingsButton.tsx",
"chars": 2097,
"preview": "import React from \"react\";\nimport {\n Box,\n IconButton,\n Menu,\n MenuButton,\n MenuItem,\n MenuList,\n useDisclosure,\n"
},
{
"path": "components/icons/ChatIcon.tsx",
"chars": 328,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chat from \"../../public/chat.svg\";\n\nexport default fu"
},
{
"path": "components/icons/ChevronIcon.tsx",
"chars": 261,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronUp from \"../../public/chevronUp.svg\";\n\nexport "
},
{
"path": "components/icons/ChevronLeftIcon.tsx",
"chars": 256,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronLeft from \"../../public/chevronLeft.svg\";\n\nexp"
},
{
"path": "components/icons/ChevronRightIcon.tsx",
"chars": 271,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport chevronRight from \"../../public/chevronRight.svg\";\n\ne"
},
{
"path": "components/icons/LeaveIcon.tsx",
"chars": 969,
"preview": "import React from \"react\";\n\nexport default function LeaveIcon(): JSX.Element {\n return (\n <svg\n width=\"17\"\n "
},
{
"path": "components/icons/MuteCameraIcon.tsx",
"chars": 339,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport cameraOnIcon from \"../../public/cameraOn.svg\";\n\nexpor"
},
{
"path": "components/icons/MuteMicrophoneIcon.tsx",
"chars": 355,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport microphoneOffIcon from \"../../public/microphoneOff.sv"
},
{
"path": "components/icons/ScreenShareIcon.tsx",
"chars": 358,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport screenShare from \"../../public/screen-share.svg\";\n\nex"
},
{
"path": "components/icons/SendIcon.tsx",
"chars": 353,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport send from \"../../public/send.svg\";\n\nexport default fu"
},
{
"path": "components/icons/SettingsIcon.tsx",
"chars": 306,
"preview": "import Image from \"next/image\";\n\nimport elipsisIcon from \"../../public/elipsis.svg\";\n\nexport default function SettingsIc"
},
{
"path": "components/icons/UnmuteCameraIcon.tsx",
"chars": 346,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport cameraOffIcon from \"../../public/cameraOff.svg\";\n\nexp"
},
{
"path": "components/icons/UnmuteMicrophoneIcon.tsx",
"chars": 371,
"preview": "import React from \"react\";\nimport Image from \"next/image\";\n\nimport microphoneOnIcon from \"../../public/microphoneOn.svg\""
},
{
"path": "components/modals/ACRScoreDialog.tsx",
"chars": 4001,
"preview": "import React, { useCallback, useRef, useState } from \"react\";\nimport {\n useDisclosure,\n Button,\n AlertDialog,\n Alert"
},
{
"path": "components/modals/ErrorModal.tsx",
"chars": 752,
"preview": "import {\n Button,\n Modal,\n ModalBody,\n ModalCloseButton,\n ModalContent,\n ModalFooter,\n ModalHeader,\n ModalOverla"
},
{
"path": "components/modals/RenameParticipantModal.tsx",
"chars": 2954,
"preview": "import React, {\n useCallback,\n useMemo,\n useRef,\n useState,\n useContext,\n} from \"react\";\nimport {\n Modal,\n ModalO"
},
{
"path": "components/renderers/AudioRenderer.tsx",
"chars": 437,
"preview": "import { useEffect, useRef } from \"react\";\n\ninterface AudioTrackProps {\n attachFunc: (element: HTMLAudioElement) => voi"
},
{
"path": "components/renderers/ChatRenderer.tsx",
"chars": 6059,
"preview": "import React, {\n FormEvent,\n KeyboardEvent,\n UIEvent,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,"
},
{
"path": "components/renderers/ScreenShareRenderer.tsx",
"chars": 706,
"preview": "import React, { useEffect, useRef } from \"react\";\nimport { Flex } from \"@chakra-ui/react\";\n\ninterface Props {\n attach: "
},
{
"path": "components/renderers/VideoRenderer.tsx",
"chars": 1476,
"preview": "import React, { useEffect, useRef, useState } from \"react\";\n\nimport poster from \"../../public/poster.jpg\";\nimport poster"
},
{
"path": "context/Chat.tsx",
"chars": 4432,
"preview": "import { CustomEvent, SpaceEvent } from \"@mux/spaces-web\";\nimport { useSpaceEvent } from \"hooks/useSpaceEvent\";\nimport R"
},
{
"path": "context/Space.tsx",
"chars": 18269,
"preview": "import React, {\n createContext,\n ReactNode,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from \"r"
},
{
"path": "context/User.tsx",
"chars": 2301,
"preview": "import React, { createContext, ReactNode, useCallback, useState } from \"react\";\nimport { v4 as uuidv4 } from \"uuid\";\n\nim"
},
{
"path": "context/UserMedia.tsx",
"chars": 12964,
"preview": "import React, {\n createContext,\n ReactNode,\n useCallback,\n useEffect,\n useMemo,\n useState,\n} from \"react\";\nimport "
},
{
"path": "hooks/useLocalStorage.tsx",
"chars": 2714,
"preview": "import {\n Dispatch,\n SetStateAction,\n useCallback,\n useEffect,\n useRef,\n useState,\n} from \"react\";\n\ndeclare global"
},
{
"path": "hooks/useParticipant.ts",
"chars": 6540,
"preview": "import React, {\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n} from \"react\";\nimport {\n LocalPartici"
},
{
"path": "hooks/useSpace.ts",
"chars": 2149,
"preview": "import { useContext } from \"react\";\nimport { AcrScore } from \"@mux/spaces-web\";\n\nimport SpaceContext from \"../context/Sp"
},
{
"path": "hooks/useSpaceEvent.tsx",
"chars": 1068,
"preview": "import { SpaceEvent } from \"@mux/spaces-web\";\nimport SpaceContext from \"context/Space\";\nimport { useContext, useEffect, "
},
{
"path": "hooks/useUserMedia.ts",
"chars": 1565,
"preview": "import { useContext } from \"react\";\n\nimport UserMediaContext from \"../context/UserMedia\";\n\ninterface UserMedia {\n userM"
},
{
"path": "hooks/useWindowDimension.ts",
"chars": 771,
"preview": "import { useEffect, useState } from \"react\";\n\ntype WindowDimensions = {\n width: number | undefined;\n height: number | "
},
{
"path": "lib/constants.ts",
"chars": 112,
"preview": "export const MAX_PARTICIPANTS_PER_PAGE = 20;\nexport const TEMPORARY_SPACE_PASSTHROUGH = \"Temporary Meet Space\";\n"
},
{
"path": "lib/gallery.ts",
"chars": 2003,
"preview": "type LayoutDescription = {\n area: number;\n cols: number;\n rows: number;\n width: number;\n height: number;\n};\n\n/**\n *"
},
{
"path": "lib/theme.ts",
"chars": 3922,
"preview": "import localFont from \"@next/font/local\";\nimport { extendTheme, defineStyle } from \"@chakra-ui/react\";\n\nconst akkuratFon"
},
{
"path": "lib/utils.ts",
"chars": 185,
"preview": "import { CreateStyled } from \"@emotion/styled\";\n\nexport const transientOptions: Parameters<CreateStyled>[1] = {\n should"
},
{
"path": "next-env.d.ts",
"chars": 201,
"preview": "/// <reference types=\"next\" />\n/// <reference types=\"next/image-types/global\" />\n\n// NOTE: This file should not be edite"
},
{
"path": "next.config.js",
"chars": 120,
"preview": "/** @type {import('next').NextConfig} */\nconst nextConfig = {\n reactStrictMode: true,\n};\n\nmodule.exports = nextConfig;\n"
},
{
"path": "package.json",
"chars": 1348,
"preview": "{\n \"name\": \"@mux/mux-meet-nextjs\",\n \"private\": true,\n \"scripts\": {\n \"dev\": \"next dev\",\n \"build\": \"next build\",\n"
},
{
"path": "pages/_app.tsx",
"chars": 903,
"preview": "import type { AppProps } from \"next/app\";\nimport { ChakraProvider } from \"@chakra-ui/react\";\nimport { QueryClient, Query"
},
{
"path": "pages/_document.tsx",
"chars": 374,
"preview": "import { ColorModeScript } from \"@chakra-ui/react\";\nimport Document, { Html, Head, Main, NextScript } from \"next/documen"
},
{
"path": "pages/api/spaces/[id].ts",
"chars": 849,
"preview": "import { StatusCodes } from \"http-status-codes\";\nimport { NextApiRequest, NextApiResponse } from \"next\";\nimport { muxCli"
},
{
"path": "pages/api/spaces.ts",
"chars": 1955,
"preview": "import axios from \"axios\";\nimport { StatusCodes } from \"http-status-codes\";\nimport { TEMPORARY_SPACE_PASSTHROUGH } from "
},
{
"path": "pages/api/token.ts",
"chars": 955,
"preview": "import { StatusCodes } from \"http-status-codes\";\nimport jwt from \"jsonwebtoken\";\nimport { NextApiRequest, NextApiRespons"
},
{
"path": "pages/api/webhooks.ts",
"chars": 1223,
"preview": "import Mux from \"@mux/mux-node\";\nimport { StatusCodes } from \"http-status-codes\";\nimport { TEMPORARY_SPACE_PASSTHROUGH }"
},
{
"path": "pages/index.tsx",
"chars": 5812,
"preview": "import React, { useEffect, useMemo, useRef, useState } from \"react\";\nimport Head from \"next/head\";\nimport { useRouter } "
},
{
"path": "pages/space/[id].tsx",
"chars": 4724,
"preview": "import React, { useCallback, useEffect, useRef, useState } from \"react\";\nimport { useMutation } from \"react-query\";\nimpo"
},
{
"path": "server-lib/services.ts",
"chars": 328,
"preview": "import axios from \"axios\";\n\nconst {\n MUX_TOKEN_ID,\n MUX_TOKEN_SECRET,\n MUX_API_HOST = \"api.mux.com\",\n} = process.env;"
},
{
"path": "shared/defaults.tsx",
"chars": 163,
"preview": "export const defaultAudioConstraints: MediaTrackConstraints = {\n autoGainControl: true,\n echoCancellation: true,\n noi"
},
{
"path": "shared/toastConfigs.tsx",
"chars": 1244,
"preview": "import { Box } from \"@chakra-ui/react\";\nimport { UseToastOptions } from \"@chakra-ui/react\";\n\nexport enum ToastIds {\n CO"
},
{
"path": "tsconfig.json",
"chars": 529,
"preview": "{\n \"compilerOptions\": {\n \"baseUrl\": \".\",\n \"target\": \"es5\",\n \"lib\": [\"dom\", \"dom.iterable\", \"esnext\"],\n \"all"
}
]
About this extraction
This page contains the full source code of the muxinc/meet GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 82 files (155.8 KB), approximately 41.0k tokens, and a symbol index with 108 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.