In part one of this two-part series, we focused on building the home page and lobby page of the Google Meet clone. We also worked on:
- Integrating user authentication
- Setting up Stream’s Video SDK to enable video calling
- Installing Stream’s Chat SDK to support chat messaging
Having laid this foundation, we can start building the meeting page so participants can interact in real-time during a video call.
In this article, we will build the UI for the meeting page by adding custom video layouts and call controls. Afterward, we’ll integrate specific features such as screen sharing, chat messaging, and call recording.
You can check out the live demo and find the final code for the project on this GitHub repository.
Building the Meeting Page
Let’s get started by building the UI of our meeting page. The page is divided into two main sections:
- Video Layout: We’ll work on designing the video layouts, which adapt to the number of participants and their activities.
- Meeting Info and Controls: We’ll also build the UI for the meeting info and controls. The controls will allow users to share their screens, record the meeting, and open the chat.
Customizing the Participant View
The core component we’ll use in our video layouts is Stream’s ParticipantView
component. This component displays the participant’s video and also plays their audio. In addition, the component also renders the participant’s information and provides action buttons for controls like pinning or muting.
However, the ParticipantView
component comes with a default user interface that doesn’t match what we want for our app. To override this default UI, we’ll make use of the following ParticipantView
props:
ParticipantViewUI
: With this property, we can customize theParticipantView
component with custom UI elements.VideoPlaceholder
: This property allows us to replace the default placeholder displayed when the video feed is not playing.
Let’s start by creating our custom participant view UI. Create a new file named ParticipantViewUI.tsx
in the components
directory and add the following code:
import {
ComponentProps,
ForwardedRef,
forwardRef,
ReactNode,
useState,
} from 'react';
import {
DefaultParticipantViewUIProps,
DefaultScreenShareOverlay,
hasAudio,
hasScreenShare,
isPinned,
MenuToggle,
OwnCapability,
ParticipantActionsContextMenu,
ToggleMenuButtonProps,
useCall,
useCallStateHooks,
useParticipantViewContext,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import Keep from './icons/Keep';
import KeepFilled from './icons/KeepFilled';
import KeepOffFilled from './icons/KeepOffFilled';
import KeepPublicFilled from './icons/KeepPublicFilled';
import MicOffFilled from './icons/MicOffFilled';
import SpeechIndicator from './SpeechIndicator';
import VisualEffects from './icons/VisualEffects';
import MoreVert from './icons/MoreVert';
export const speechRingClassName = 'speech-ring';
export const menuOverlayClassName = 'menu-overlay';
const ParticipantViewUI = () => {
const call = useCall();
const { useHasPermissions } = useCallStateHooks();
const { participant, trackType } = useParticipantViewContext();
const [showMenu, setShowMenu] = useState(false);
const {
pin,
sessionId,
isLocalParticipant,
isSpeaking,
isDominantSpeaker,
userId,
} = participant;
const isScreenSharing = hasScreenShare(participant);
const hasAudioTrack = hasAudio(participant);
const canUnpinForEveryone = useHasPermissions(OwnCapability.PIN_FOR_EVERYONE);
const pinned = isPinned(participant);
const unpin = () => {
if (pin?.isLocalPin || !canUnpinForEveryone) {
call?.unpin(sessionId);
} else {
call?.unpinForEveryone({
user_id: userId,
session_id: sessionId,
});
}
};
if (isLocalParticipant && isScreenSharing && trackType === 'screenShareTrack')
return (
<>
<DefaultScreenShareOverlay />
<ParticipantDetails />
</>
);
return (
<>
<ParticipantDetails />
{hasAudioTrack && (
<div className="absolute top-3.5 right-3.5 w-6.5 h-6.5 flex items-center justify-center bg-primary rounded-full">
<SpeechIndicator
isSpeaking={isSpeaking}
isDominantSpeaker={isDominantSpeaker}
/>
</div>
)}
{!hasAudioTrack && (
<div className="absolute top-3.5 right-3.5 w-6.5 h-6.5 flex items-center justify-center bg-[#2021244d] rounded-full">
<MicOffFilled width={18} height={18} />
</div>
)}
{/* Speech Ring */}
<div
className={clsx(
isSpeaking &&
hasAudioTrack &&
'ring-[5px] ring-inset ring-light-blue',
`absolute left-0 top-0 w-full h-full rounded-xl ${speechRingClassName}`
)}
/>
{/* Menu Overlay */}
<div
onMouseOver={() => {
setShowMenu(true);
}}
onMouseOut={() => setShowMenu(false)}
className={`absolute z-1 left-0 top-0 w-full h-full rounded-xl bg-transparent ${menuOverlayClassName}`}
/>
{/* Menu */}
<div
className={clsx(
showMenu ? 'opacity-60' : 'opacity-0',
'z-2 absolute left-[calc(50%-66px)] top-[calc(50%-22px)] flex items-center justify-center h-11 transition-opacity duration-300 ease-linear overflow-hidden',
'shadow-[0_1px_2px_0px_rgba(0,0,0,0.3),_0_1px_3px_1px_rgba(0,0,0,.15)] bg-meet-black rounded-full h-11 hover:opacity-90'
)}
>
<div className="[&_ul>*:nth-child(n+4)]:hidden">
{!pinned && (
<MenuToggle
strategy="fixed"
placement="bottom-start"
ToggleButton={PinMenuToggleButton}
>
<ParticipantActionsContextMenu />
</MenuToggle>
)}
{pinned && (
<Button title="Unpin" onClick={unpin} icon={<KeepOffFilled />} />
)}
</div>
<Button title="Apply visual effects" icon={<VisualEffects />} />
<div className="[&_ul>*:nth-child(-n+3)]:hidden">
<MenuToggle
strategy="fixed"
placement="bottom-start"
ToggleButton={OtherMenuToggleButton}
>
<ParticipantActionsContextMenu />
</MenuToggle>
</div>
</div>
</>
);
};
const ParticipantDetails = ({}: Pick<
DefaultParticipantViewUIProps,
'indicatorsVisible'
>) => {
const { participant } = useParticipantViewContext();
const { pin, name, userId } = participant;
const pinned = !!pin;
return (
<>
<div className="z-1 absolute left-0 bottom-[.65rem] max-w-94 h-fit truncate font-medium text-white text-sm flex items-center justify-start gap-4 mt-1.5 mx-4 mb-0 cursor-default select-none">
{pinned && (pin.isLocalPin ? <KeepFilled /> : <KeepPublicFilled />)}
<span
style={{
textShadow: '0 1px 2px rgba(0,0,0,.6), 0 0 2px rgba(0,0,0,.3)',
}}
>
{name || userId}
</span>
</div>
</>
);
};
const Button = forwardRef(function Button(
{
icon,
onClick = () => null,
menuShown,
...rest
}: {
icon: ReactNode;
onClick?: () => void;
} & ComponentProps<'button'> & { menuShown?: boolean },
ref: ForwardedRef<HTMLButtonElement>
) {
return (
<button
onClick={(e) => {
e.preventDefault();
onClick?.(e);
}}
{...rest}
ref={ref}
className="h-11 w-11 rounded-full p-2.5 bg-transparent border-transparent outline-none hover:bg-[rgba(232,234,237,.15)] transition-[background] duration-150 ease-linear"
>
{icon}
</button>
);
});
const PinMenuToggleButton = forwardRef<
HTMLButtonElement,
ToggleMenuButtonProps
>(function ToggleButton(props, ref) {
return <Button {...props} title="Pin" ref={ref} icon={<Keep />} />;
});
const OtherMenuToggleButton = forwardRef<
HTMLButtonElement,
ToggleMenuButtonProps
>(function ToggleButton(props, ref) {
return (
<Button {...props} title="More options" ref={ref} icon={<MoreVert />} />
);
});
export default ParticipantViewUI;
The ParticipantViewUI
component is made up of several smaller components:
- Screenshare Overlay: When the participant is screen sharing, we render the
DefaultScreenShareOverlay
component along with the participant's details. - Participant Details: In this component, we get the participant’s data using the
useParticipantViewContext
hook. We then use this data to display the participant's name and indicate if they are pinned. - Speech Indicator: If the participant has an audio track, we show a visual indicator using the
SpeechIndicator
component. If their microphone is muted, we display a muted icon instead. - Speech Ring: We use the
hasAudio
function to determine whether a participant is speaking. If true, we display a visual ring around the participant's video. - Menu Overlay and Controls: When hovering over a participant's video, a menu appears with options to pin, unpin, or access more settings.
Next, let’s build our custom video placeholder. Create a VideoPlaceholder.tsx
file in the components
directory with the following content:
import { forwardRef, useMemo } from 'react';
import Image from 'next/image';
import {
useParticipantViewContext,
type VideoPlaceholderProps,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import useUserColor from '../hooks/useUserColor';
export const placeholderClassName = 'participant-view-placeholder';
const WIDTH = 160;
const VideoPlaceholder = forwardRef<HTMLDivElement, VideoPlaceholderProps>(
function VideoPlaceholder({ style }, ref) {
const color = useUserColor();
const { participant } = useParticipantViewContext();
const name = participant.name || participant.userId;
const randomColor = useMemo(() => {
return color(name);
}, [color, name]);
return (
<div
ref={ref}
style={style}
className={`absolute w-full h-full rounded-[inherit] bg-dark-gray flex items-center justify-center ${placeholderClassName}`}
>
{participant.image && (
<Image
className="max-w-3/10 rounded-full overflow-hidden"
src={participant.image}
alt={participant.userId}
width={WIDTH}
height={WIDTH}
/>
)}
<div
style={{
backgroundColor: randomColor,
}}
className={clsx(
participant.image && 'hidden',
'relative avatar w-3/10 max-w-40 aspect-square uppercase rounded-full text-white font-sans-serif font-medium flex items-center justify-center'
)}
>
<span className="text-[clamp(30px,_calc(100vw_*_0.05),_65px)] select-none">
{name[0]}
</span>
</div>
</div>
);
}
);
export default VideoPlaceholder;
In the code above, we display an image if the participant has one. Otherwise, we show their initials with a background color derived from their name, using the useUserColor
hook.
With these components in place, we can begin working on our video layouts.
Creating the Video Layouts
We’re going to use two main video layouts for our meeting page:
GridLayout
: This component arranges participants in a grid format. We’ll use this layout when there’s no participant currently pinned or sharing their screen so everyone is equally visible.SpeakerLayout
: This component highlights the participant currently pinned or screen sharing. It places them in the spotlight while it shows the others in a smaller participant bar.
Let’s begin by building our GridLayout
component. Create a file named GridLayout.tsx
in the components
directory with the following code:
import { useEffect, useMemo, useState } from 'react';
import {
combineComparators,
Comparator,
IconButton,
ParticipantView,
pinned,
screenSharing,
StreamVideoParticipant,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import ParticipantViewUI from './ParticipantViewUI';
import VideoPlaceholder from './VideoPlaceholder';
const GROUP_SIZE = 6;
const GridLayout = () => {
const call = useCall();
const { useParticipants } = useCallStateHooks();
const participants = useParticipants();
const [page, setPage] = useState(0);
const pageCount = useMemo(
() => Math.ceil(participants.length / GROUP_SIZE),
[participants]
);
const participantGroups = useMemo(() => {
// divide participants into groups of 6
const groups = [];
for (let i = 0; i < participants.length; i += GROUP_SIZE) {
groups.push(participants.slice(i, i + GROUP_SIZE));
}
return groups;
}, [participants]);
const selectedGroup = participantGroups[page];
useEffect(() => {
if (!call) return;
const customSortingPreset = getCustomSortingPreset();
call.setSortParticipantsBy(customSortingPreset);
}, [call]);
useEffect(() => {
if (page > pageCount - 1) {
setPage(Math.max(0, pageCount - 1));
}
}, [page, pageCount]);
const getCustomSortingPreset = (): Comparator<StreamVideoParticipant> => {
return combineComparators(screenSharing, pinned);
};
return (
<div
className={clsx(
'w-full relative overflow-hidden',
'str-video__paginated-grid-layout'
)}
>
{pageCount > 1 && (
<IconButton
icon="caret-left"
disabled={page === 0}
onClick={() => setPage((currentPage) => Math.max(0, currentPage - 1))}
/>
)}
<div
className={clsx('str-video__paginated-grid-layout__group', {
'str-video__paginated-grid-layout--one': selectedGroup.length === 1,
'str-video__paginated-grid-layout--two-four':
selectedGroup.length >= 2 && selectedGroup.length <= 4,
'str-video__paginated-grid-layout--five-nine':
selectedGroup.length >= 5 && selectedGroup.length <= 9,
})}
>
{call && selectedGroup.length > 0 && (
<>
{selectedGroup.map((participant) => (
<ParticipantView
participant={participant}
ParticipantViewUI={ParticipantViewUI}
VideoPlaceholder={VideoPlaceholder}
key={participant.sessionId}
/>
))}
</>
)}
</div>
{pageCount > 1 && (
<IconButton
disabled={page === pageCount - 1}
icon="caret-right"
onClick={() =>
setPage((currentPage) => Math.min(pageCount - 1, currentPage + 1))
}
/>
)}
</div>
);
};
export default GridLayout;
In the snippet above:
- We group participants into pages, each containing up to six participants (
GROUP_SIZE = 6
) - We calculate the total number of pages (
pageCount
) and divide participants into groups accordingly. - We set a custom sorting order for participants using
combineComparators
, prioritizing those who are screen-sharing or pinned. - Most of the styling used in the layout is borrowed from Stream's CSS stylesheet.
- The layout adjusts its styling based on the number of participants displayed and includes navigation buttons (
IconButton
) to move between pages when necessary.
Next, let’s create our SpeakerLayout
component. Create a file named SpeakerLayout.tsx
in the components
directory:
import { useEffect, useState } from 'react';
import {
combineComparators,
Comparator,
hasScreenShare,
ParticipantView,
pinned,
screenSharing,
StreamVideoParticipant,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import ParticipantViewUI from './ParticipantViewUI';
import VideoPlaceholder from './VideoPlaceholder';
const SpeakerLayout = () => {
const call = useCall();
const { useParticipants } = useCallStateHooks();
const participants = useParticipants();
const [participantInSpotlight, ...otherParticipants] = participants;
const [participantsBar, setParticipantsBar] = useState<HTMLDivElement | null>(
null
);
const getCustomSortingPreset = (): Comparator<StreamVideoParticipant> => {
return combineComparators(screenSharing, pinned);
};
useEffect(() => {
if (!call) return;
const customSortingPreset = getCustomSortingPreset();
call.setSortParticipantsBy(customSortingPreset);
}, [call]);
useEffect(() => {
if (!participantsBar || !call) return;
const cleanup = call.dynascaleManager.setViewport(participantsBar);
return () => cleanup();
}, [participantsBar, call]);
return (
<div
className="w-full relative overflow-hidden str-video__speaker-layout str-video__speaker-layout--variant-bottom"
>
<div className="str-video__speaker-layout__wrapper">
<div
className={
participants.length > 1
? 'str-video__speaker-layout__spotlight'
: 'spotlight--one'
}
>
{call && participantInSpotlight && (
<ParticipantView
participant={participantInSpotlight}
trackType={
hasScreenShare(participantInSpotlight)
? 'screenShareTrack'
: 'videoTrack'
}
ParticipantViewUI={ParticipantViewUI}
VideoPlaceholder={VideoPlaceholder}
/>
)}
</div>
{call && otherParticipants.length > 0 && (
<div className="str-video__speaker-layout__participants-bar-buttons-wrapper">
<div className="str-video__speaker-layout__participants-bar-wrapper">
<div
ref={setParticipantsBar}
className="str-video__speaker-layout__participants-bar"
>
{otherParticipants.map((participant) => (
<div
key={participant.sessionId}
className="str-video__speaker-layout__participant-tile"
>
<ParticipantView
participant={participant}
ParticipantViewUI={ParticipantViewUI}
VideoPlaceholder={VideoPlaceholder}
/>
</div>
))}
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default SpeakerLayout;
In the code above:
- We display the pinned speaker or screen sharer in a spotlight view and arrange the other participants in a participant bar below.
- Like with our
GridLayout
component, this component also uses custom sorting to prioritize participants who are screen-sharing or pinned. - We utilize a
useEffect
to update the sorting and manage the viewport for dynamic scaling.
Next, let’s add some custom CSS styling to modify the default layout classes:
...
@layer components {
...
.root-theme .str-video__paginated-grid-layout,
.root-theme .str-video__speaker-layout.str-video__speaker-layout--variant-bottom {
height: calc(100% - 5rem);
padding: 1rem 1rem 0 1rem;
}
.root-theme .str-video__speaker-layout__wrapper {
flex-grow: 0;
}
.root-theme .str-video__speaker-layout .str-video__speaker-layout__participants-bar-wrapper .str-video__speaker-layout__participants-bar .str-video__speaker-layout__participant-tile {
min-width: 350px;
}
.root-theme .str-video__speaker-layout__participants-bar-wrapper {
@apply min-[896px]:w-full;
}
.root-theme .str-video__speaker-layout__participants-bar {
scrollbar-width: none;
@apply overflow-y-hidden min-[896px]:flex min-[896px]:justify-center min-[896px]:items-center min-[896px]:w-full;
}
.root-theme .str-video__paginated-grid-layout .str-video__paginated-grid-layout__group,
.root-theme .spotlight {
max-width: 1316px;
max-height: calc(100svh - 6rem);
padding: 0;
gap: 12px;
position: relative;
}
.root-theme .str-video__paginated-grid-layout .str-video__paginated-grid-layout__group:has(> .str-video__participant-view:first-child:nth-last-child(2)) {
@apply flex-col min-[500px]:flex-row;
}
.root-theme .str-video__paginated-grid-layout--one .str-video__participant-view .str-video__menu-container,
.root-theme .str-video__participant-view:first-child:nth-last-child(2) .str-video__menu-container,
.root-theme .str-video__participant-view:first-child:nth-last-child(2)~.str-video__participant-view .str-video__menu-container {
max-height: 380px !important;
}
.root-theme .spotlight--one>.str-video__participant-view,
.root-theme .str-video__paginated-grid-layout--one .str-video__participant-view {
border-radius: 0;
max-height: calc(100svh - 6rem);
max-width: 1294px;
margin: 0 auto;
}
.root-theme .str-video__participant-view {
position: relative;
@apply animate-delayed-fade-in;
}
.root-theme .str-video__paginated-grid-layout--one .participant-view-placeholder {
background: transparent;
}
.root-theme .str-video__paginated-grid-layout--one .speech-ring {
box-shadow: none;
}
.root-theme .str-video__participant-view--speaking {
outline: none;
}
.root-theme .str-video__paginated-grid-layout--two-four .str-video__participant-view {
max-height: calc(calc(100svh - 6rem) / 2 - 6px);
}
.root-theme .str-video__menu-container {
background: #303134;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, .3),
0 2px 6px 2px rgba(0, 0, 0, .15);
border-radius: 4px;
max-height: 158px !important;
}
.root-theme .str-video__generic-menu {
padding: 8px 0;
gap: 4px;
}
.root-theme .str-video__generic-menu--item {
background: transparent;
border-radius: 0;
padding: 0;
height: 40px;
}
.root-theme .str-video__generic-menu .str-video__generic-menu--item button {
border-radius: 0;
background: transparent;
color: #e8eaed;
padding: 0 16px;
height: 100%;
font-family: Roboto, Arial, sans-serif;
line-height: 1.25rem;
font-size: .875rem;
letter-spacing: .0142857143em;
font-weight: 400;
}
.root-theme .str-video__generic-menu .str-video__generic-menu--item button:hover {
background: #37383b;
}
.root-theme .str-video__speaker-layout__participants-bar-buttons-wrapper {
overflow: auto;
}
.root-theme .str-video__speaker-layout--variant-bottom .str-video__speaker-layout__participants-bar-wrapper {
overflow-x: auto;
}
.root-theme .str-video__screen-share-overlay__title {
color: white;
}
...
}
...
With our layouts ready, let’s add them to our meeting page.
Create a meeting
directory inside the [meetingId]
folder, then create a page.tsx
file with the following code:
'use client';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
CallingState,
hasScreenShare,
isPinned,
StreamTheme,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import GridLayout from '@/components/GridLayout';
import SpeakerLayout from '@/components/SpeakerLayout';
import useTime from '@/hooks/useTime';
interface MeetingProps {
params: {
meetingId: string;
};
}
const Meeting = ({ params }: MeetingProps) => {
const { meetingId } = params;
const audioRef = useRef<HTMLAudioElement>(null);
const router = useRouter();
const { currentTime } = useTime();
const { useCallCallingState, useParticipants } =
useCallStateHooks();
const participants = useParticipants();
const callingState = useCallCallingState();
const [participantInSpotlight, _] = participants;
const [prevParticipantsCount, setPrevParticipantsCount] = useState(0);
const isUnkownOrIdle =
callingState === CallingState.UNKNOWN || callingState === CallingState.IDLE;
useEffect(() => {
const startup = async () => {
if (isUnkownOrIdle) {
router.push(`/${meetingId}`);
}
};
startup();
}, [router, meetingId, isUnkownOrIdle]);
useEffect(() => {
if (participants.length > prevParticipantsCount) {
audioRef.current?.play();
setPrevParticipantsCount(participants.length);
}
}, [participants.length, prevParticipantsCount]);
const isSpeakerLayout = useMemo(() => {
if (participantInSpotlight) {
return (
hasScreenShare(participantInSpotlight) ||
isPinned(participantInSpotlight)
);
}
return false;
}, [participantInSpotlight]);
if (isUnkownOrIdle) return null;
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
{isSpeakerLayout && <SpeakerLayout />}
{!isSpeakerLayout && <GridLayout />}
<div className="absolute left-0 bottom-0 right-0 w-full h-20 bg-meet-black text-white text-center flex items-center justify-between">
{/* Meeting ID */}
<div className="hidden sm:flex grow shrink basis-1/4 items-center text-start justify-start ml-3 truncate max-w-full">
<div className="flex items-center overflow-hidden mx-3 h-20 gap-3 select-none">
<span className="font-medium">{currentTime}</span>
<span>{'|'}</span>
<span className="font-medium truncate">{meetingId}</span>
</div>
</div>
</div>
<audio
ref={audioRef}
src="https://www.gstatic.com/meet/sounds/join_call_6a6a67d6bcc7a4e373ed40fdeff3930a.ogg"
/>
</div>
</StreamTheme>
);
};
export default Meeting;
Let's explain what's going on here:
- State Management:
- We use the
useCallCallingState
hook to monitor the state of the call (e.g., connected, disconnected). - The
useParticipants
hook provides an array of current participants in the call.
- We use the
- Effect Handling:
- If the call is inactive, we redirect the user to the lobby page.
- When a new participant joins, we play a notification sound.
- Layout Selection:
- We use
useMemo
to decide whether to use theSpeakerLayout
orGridLayout
based on the participants' activities. - The selected layout component is rendered accordingly.
- We display the current time and meeting ID at the bottom of the screen
- We use
- Theme Application:
- We wrap the component with
StreamTheme
to apply consistent theming across the meeting page.
- We wrap the component with
And with that, we should have our layout set up!
Animating the Video Layouts
Now that we’ve added our layout, let’s make the interface feel more fluid by adding animations. We’ll add different transitions for when:
- Participants join
- Participants leave
- The layout changes
To achieve this, we'll use the GreenSock Animation Platform (GSAP). GSAP is a powerful library for building high-performance animations in JavaScript.
First, we need to install GSAP and its React plugin:
npm install gsap @gsap/react
Next, we'll create a custom hook that will handle the animations whenever the video layout changes.
Create a useAnimateVideoLayout.tsx
file in the hooks
directory with the following code:
'use client';
import { useRef } from 'react';
import gsap from 'gsap';
import { useGSAP } from '@gsap/react';
import { avatarClassName } from '../components/Avatar';
import {
menuOverlayClassName,
speechRingClassName,
} from '../components/ParticipantViewUI';
import { placeholderClassName } from '../components/VideoPlaceholder';
type PreviousValues = Map<
string,
{
x: number;
y: number;
width: number;
height: number;
total: number;
}
>;
gsap.registerPlugin(useGSAP);
const useAnimateVideoLayout = (isSpeakerLayout: boolean = false) => {
const previousRef = useRef<PreviousValues>(new Map());
const ref = useRef<HTMLDivElement>(null);
useGSAP(
(_, contextSafe) => {
if (!ref.current) return;
let container = (
isSpeakerLayout
? ref.current!.querySelector(
'.str-video__speaker-layout__participants-bar'
)
: ref.current!.querySelector(
'.str-video__paginated-grid-layout__group'
)
) as HTMLElement;
const animateItems = contextSafe!(() => {
const items = Array.from(
ref.current!.querySelectorAll('.str-video__participant-view')
);
let layout = ref.current as HTMLElement;
items.forEach((item, index) => {
const { left, top, width, height } = item.getBoundingClientRect();
const container = layout.getBoundingClientRect();
const startX = left - container.left;
const startY = top - container.top;
const id = index.toString();
const prevPosition = previousRef.current.get(id) || {
x: startX,
y: startY,
width,
height,
total: items.length,
};
// Calculate scale factors
const scaleX = prevPosition.width / width;
const scaleY = prevPosition.height / height;
const getTranslateValues = () => {
if (items.length === 1) {
return [-prevPosition.width / 2, 0];
} else if (
index === 0 &&
items.length === 2 &&
prevPosition.total === 1
) {
return [prevPosition.width / 4, 0];
} else {
return [prevPosition.x - startX, prevPosition.y - startY];
}
};
const [x, y] = getTranslateValues();
const innerFrom = {
scaleX: 1 / scaleX,
scaleY: 1 / scaleY,
};
// animate element's children
item
.querySelectorAll(
`:scope > :not(video):not(.${menuOverlayClassName}):not(.${placeholderClassName}):not(.${speechRingClassName})`
)
.forEach((el) => {
gsap.fromTo(el, innerFrom, {
scaleX: 1,
scaleY: 1,
duration: 0.5,
ease: 'power3.inOut',
onComplete: () => {
gsap.set(el, { attr: { style: '' } });
},
});
});
const video = item.querySelector('video');
if (!video) {
const avatar = item.querySelector(`.${avatarClassName}`);
if (avatar) {
gsap.fromTo(avatar, innerFrom, {
scaleX: 1,
scaleY: 1,
duration: 0.5,
ease: 'power3.inOut',
});
}
}
// animate video cover when there are 3 participants or less
if (
items.length < 3 ||
(items.length === 3 && prevPosition.total === 2) ||
(items.length === 4 && prevPosition.total === 5) ||
(items.length === 5 && prevPosition.total === 4)
) {
if (video) {
gsap.fromTo(
item.querySelector(`.${menuOverlayClassName}`),
{
background: 'var(--meet-black)',
opacity: 1,
outlineWidth: 2,
outlineStyle: 'solid',
outlineColor: 'var(--meet-black)',
...(items.length === 1 && {
borderRadius: '0px',
}),
},
{
opacity: 0,
outlineWidth: 2,
duration: 0.8,
ease: 'power2.inOut',
onComplete: () => {
gsap.set(item.querySelector(`.${menuOverlayClassName}`), {
attr: { style: '' },
});
},
}
);
}
}
// animate element
gsap.fromTo(
item,
{
x,
y,
scaleX,
scaleY,
},
{
x: 0,
y: 0,
scaleX: 1,
scaleY: 1,
duration: 0.5,
ease: 'power3.inOut',
onComplete: () => {
gsap.set(item, { attr: { style: '' } });
},
}
);
previousRef.current.set(id, {
x: startX,
y: startY,
width,
height,
total: items.length,
});
});
});
// Set up observer to detect changes
const observer = new MutationObserver(animateItems);
const config = { childList: true };
if (container) {
observer.observe(container, config);
}
animateItems();
// Cleanup observer on unmount
return () => {
if (container) {
observer.disconnect();
}
};
},
{ scope: ref }
);
return { ref };
};
export default useAnimateVideoLayout;
Let's loook at some key things going on here:
- We define a
PreviousValues
type to store the previous positions, sizes, and total counts of participant tiles. - We register the GSAP plugin using
gsap.registerPlugin
. - The
useAnimateVideoLayout
hook accepts a boolean parameterisSpeakerLayout
to adjust animations based on the current layout. - We use
useRef
to store a reference to the container element (ref
) and the previous values (previousRef
). - We use the
useGSAP
hook provided by@gsap/react
to integrate GSAP animations within React's lifecycle. - Inside
useGSAP
, we define the animation logic that runs whenever the layout changes. - We set up a
MutationObserver
to watch for changes in the DOM (e.g., when participants join or leave) and trigger animations accordingly. - The hook returns the
ref
object, which needs to be attached to the container element in our layout components.
Now that we have the useAnimateVideoLayout
hook, we'll integrate it into our layouts to enable animations.
In GridLayout.tsx
, update the code as follows:
...
import useAnimateVideoLayout from '../hooks/useAnimateVideoLayout';
...
const GridLayout = () => {
...
const { ref } = useAnimateVideoLayout(false);
...
return (
<div
ref={ref}
className={...}
>
...
</div>
);
};
export default GridLayout;
Next, let’s do the same for our SpeakerLayout.tsx
file:
...
import useAnimateVideoLayout from '../hooks/useAnimateVideoLayout';
...
const SpeakerLayout = () => {
...
const { ref } = useAnimateVideoLayout(true);
...
return (
<div
ref={ref}
className="..."
>
...
</div>
);
};
export default SpeakerLayout;
And with that, our layouts should animate smoothly when the ordering or number of participants changes.
Building the Call Controls
Next, we'll add call control buttons to allow users to interact with the meeting. Some of these controls include:
- Toggling the microphone and camera
- Toggling screen sharing
- Recording a call
- Ending the call
- Opening the chat
We’ll start by creating a CallControlButton
component that serves as the button for all the main call controls. This component extends the existing IconButton
component and adds a call-control-button
class to modify the default style.
In the components directory, create a CallControlButton.tsx
file with the following code:
import IconButton, { IconButtonProps } from './IconButton';
import clsx from 'clsx';
interface CallControlButtonProps extends Omit<IconButtonProps, 'variant'> {}
const CallControlButton = ({
active,
alert,
className,
icon,
onClick,
title,
}: CallControlButtonProps) => {
return (
<IconButton
variant="secondary"
active={active}
alert={alert}
icon={icon}
title={title}
className={clsx('call-control-button', className)}
onClick={onClick}
/>
);
};
export default CallControlButton;
Next, we’ll create a CallInfoButton
component similar to our CallControlButton
. This component will be used for actions like viewing meeting details or accessing the chat.
Create a CallInfoButton.tsx
file in the components
folder with the following code:
import IconButton, { IconButtonProps } from './IconButton';
import clsx from 'clsx';
interface CallInfoButtonProps extends Omit<IconButtonProps, 'variant'> {}
const CallInfoButton = ({
active,
alert,
className,
icon,
onClick,
title,
}: CallInfoButtonProps) => {
return (
<IconButton
variant="secondary"
active={active}
alert={alert}
icon={icon}
title={title}
className={clsx('call-info-button', className)}
onClick={onClick}
/>
);
};
export default CallInfoButton;
Next, we need to create a special components for our video and audio control buttons. These buttons will have a dropdown that contains their respective device selectors. They'll be designed this way so users can both toggle their media and also change their device settings at any point in the meeting.
The first component we'll work on is the ToggleButtonContainer
component. This container handles the display of device selectors when a user interacts with the button.
In the components
directory, create a ToggleButtonContainer.tsx
file with the following code:
import { MutableRefObject, useState } from 'react';
import clsx from 'clsx';
import ExpandLess from './icons/ExpandLess';
import ExpandMore from './icons/ExpandMore';
import Settings from './icons/Settings';
import useClickOutside from '../hooks/useClickOutside';
interface ToggleButtonContainerProps {
children: React.ReactNode;
deviceSelectors: React.ReactNode;
icons?: React.ReactNode;
}
const ToggleButtonContainer = ({
children,
deviceSelectors,
icons,
}: ToggleButtonContainerProps) => {
const [isOpen, setIsOpen] = useState(false);
const buttonRef = useClickOutside(() => {
setIsOpen(false);
}, true) as MutableRefObject<HTMLDivElement>;
const toggleMenu = () => {
setIsOpen((prev) => !prev);
};
return (
<div className="flex items-center h-10 bg-meet-dark-gray rounded-full">
<div
className={clsx(
isOpen ? 'block' : 'hidden',
'z-3 absolute left-0 bottom-13 h-14 w-[30.25rem] flex items-center justify-between p-2.5 bg-container-gray rounded-full shadow-[0_2px_2px_0_rgba(0,0,0,.14),0_3px_1px_-2px_rgba(0,0,0,.12),0_1px_5px_0_rgba(0,0,0,.2)]'
)}
>
<div className="flex self-start items-start gap-2.5">
{deviceSelectors}
</div>
<div className="flex items-center gap-0.5 [&>div]:w-9 [&>div]:h-9 [&>div]:flex [&>div]:items-center [&>div]:justify-center [&>div]:px-1 [&>div]:py-1.5 [&>div]:rounded-full [&>div]:cursor-pointer [&>div:hover]:bg-[#333]">
{icons}
<div title="Settings">
<Settings width={20} height={20} color="white" />
</div>
</div>
</div>
<div
ref={buttonRef}
onClick={toggleMenu}
title="Audio settings"
className="hidden h-full w-6.5 sm:flex items-center justify-center cursor-pointer"
>
<div className="h-6 w-6 flex justify-center items-center [&>svg]:ml-[3px]">
{isOpen ? (
<ExpandMore width={18} height={18} color="var(--icon-blue)" />
) : (
<ExpandLess width={18} height={18} />
)}
</div>
</div>
{children}
</div>
);
};
export default ToggleButtonContainer;
In the code above:
- We use the custom
useClickOutside
hook to close the menu when the user clicks outside the component. - We manage an
isOpen
state to control the visibility of a dropdown menu that contains thedeviceSelectors
andicons
. - We render our call control button as the
children
prop.
With our container in place, let’s add the buttons.
In the components
directory, add a ToggleVideoButton.tsx
file with the following content:
import { useCallStateHooks } from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import CallControlButton from './CallControlButton';
import ToggleButtonContainer from './ToggleButtonContainer';
import Videocam from './icons/Videocam';
import VideocamOff from './icons/VideocamOff';
import VisualEffects from './icons/VisualEffects';
import { VideoInputDeviceSelector } from './DeviceSelector';
const ICON_SIZE = 20;
const ToggleVideoButton = () => {
const { useCameraState } = useCallStateHooks();
const {
camera,
optimisticIsMute: isCameraMute,
hasBrowserPermission,
} = useCameraState();
const toggleCamera = async () => {
try {
await camera.toggle();
} catch (error) {
console.error(error);
}
};
return (
<ToggleButtonContainer
deviceSelectors={
<VideoInputDeviceSelector
className="w-[23.125rem]"
dark
disabled={!hasBrowserPermission}
/>
}
icons={
<div title="Apply visual effects">
<VisualEffects width={ICON_SIZE} height={ICON_SIZE} />
</div>
}
>
<CallControlButton
icon={
isCameraMute ? (
<VideocamOff width={ICON_SIZE} height={ICON_SIZE} />
) : (
<Videocam width={ICON_SIZE} height={ICON_SIZE} />
)
}
title={isCameraMute ? 'Turn on camera' : 'Turn off camera'}
onClick={toggleCamera}
active={isCameraMute}
alert={!hasBrowserPermission}
className={clsx(isCameraMute && 'toggle-button-alert')}
/>
</ToggleButtonContainer>
);
};
export default ToggleVideoButton;
The ToggleVideoButton
component renders a ToggleButtonContainer
, which includes the VideoInputDeviceSelector
as a prop for choosing the video input devices. The container also wraps around the CallControlButton
for our video.
Next, let’s create the button for our audio. In the components
folder, create a ToggleAudioButton.tsx
file with the following code:
import { useCallStateHooks } from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import {
AudioInputDeviceSelector,
AudioOutputDeviceSelector,
} from './DeviceSelector';
import CallControlButton from './CallControlButton';
import MicFilled from './icons/MicFilled';
import MicOffFilled from './icons/MicOffFilled';
import ToggleButtonContainer from './ToggleButtonContainer';
const ICON_SIZE = 20;
const ToggleAudioButton = () => {
const { useMicrophoneState } = useCallStateHooks();
const {
microphone,
optimisticIsMute: isMicrophoneMute,
hasBrowserPermission,
} = useMicrophoneState();
const toggleMicrophone = async () => {
try {
await microphone.toggle();
} catch (error) {
console.error(error);
}
};
return (
<ToggleButtonContainer
deviceSelectors={
<>
<AudioInputDeviceSelector
className="w-[12.375rem]"
dark
disabled={!hasBrowserPermission}
/>
<AudioOutputDeviceSelector
className="w-[12.375rem]"
dark
disabled={!hasBrowserPermission}
/>
</>
}
>
<CallControlButton
icon={
isMicrophoneMute ? (
<MicOffFilled width={ICON_SIZE} height={ICON_SIZE} />
) : (
<MicFilled width={ICON_SIZE} height={ICON_SIZE} />
)
}
title={isMicrophoneMute ? 'Turn on microphone' : 'Turn off microphone'}
onClick={toggleMicrophone}
active={isMicrophoneMute}
alert={!hasBrowserPermission}
className={clsx(isMicrophoneMute && 'toggle-button-alert')}
/>
</ToggleButtonContainer>
);
};
export default ToggleAudioButton;
In the code above:
- Similar to the
ToggleVideoButton
, ourToggleAudioButton
is composed of aCallControlButton
wrapped around theToggleButtonContainer
. - The
ToggleButtonContainer
takes in a React fragment containingAudioInputDeviceSelector
andAudioOutputDeviceSelector
as thedeviceSelector
prop.
Now that we’re done with the components, let’s update our globals.css
file with styles for the buttons to ensure they align with our app's theme:
...
@layer components {
.call-control-button,
.call-info-button {
@apply rounded-full;
}
.call-control-button {
@apply !w-10 !h-10 bg-dark-gray !border-dark-gray;
&:hover {
@apply !bg-[#444649];
}
}
.call-info-button {
@apply !w-12 !h-12 !bg-transparent !border-transparent p-3;
&:hover {
@apply !bg-[#28292c];
}
}
.toggle-button-alert,
.leave-call-button {
@apply !bg-meet-red !border-meet-red;
&:hover {
@apply !bg-hover-red;
}
}
.leave-call-button {
@apply !w-14;
}
...
}
...
Finally, let’s add the buttons to our meeting page. In the meeting
folder, update the page.tsx
file as follows:
...
import {
CallingState,
hasScreenShare,
isPinned,
StreamTheme,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
...
import CallControlButton from '@/components/CallControlButton';
import CallInfoButton from '@/components/CallInfoButton';
import CallEndFilled from '@/components/icons/CallEndFilled';
import Chat from '@/components/icons/Chat';
import ClosedCaptions from '@/components/icons/ClosedCaptions';
import Group from '@/components/icons/Group';
import Info from '@/components/icons/Info';
import Mood from '@/components/icons/Mood';
import PresentToAll from '@/components/icons/PresentToAll';
import MoreVert from '@/components/icons/MoreVert';
import ToggleAudioButton from '@/components/ToggleAudioButton';
import ToggleVideoButton from '@/components/ToggleVideoButton';
...
const Meeting = ({ params }: MeetingProps) => {
...
const call = useCall();
...
const leaveCall = async () => {
await call?.leave();
router.push(`/${meetingId}/meeting-end`);
};
...
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
...
<div className="absolute left-0 bottom-0 right-0 w-full h-20 bg-meet-black text-white text-center flex items-center justify-between">
...
{/* Meeting Controls */}
<div className="relative flex grow shrink basis-1/4 items-center justify-center px-1.5 gap-3 ml-0">
<ToggleAudioButton />
<ToggleVideoButton />
<CallControlButton
icon={<ClosedCaptions />}
title={'Turn on captions'}
/>
<CallControlButton
icon={<Mood />}
title={'Send a reaction'}
className="hidden sm:inline-flex"
/>
<CallControlButton
icon={<PresentToAll />}
title={'Present now'}
/>
<CallControlButton
icon={<MoreVert />}
title={'View recording list'}
/>
<CallControlButton
onClick={leaveCall}
icon={<CallEndFilled />}
title={'Leave call'}
className="leave-call-button"
/>
</div>
{/* Meeting Info */}
<div className="hidden sm:flex grow shrink basis-1/4 items-center justify-end mr-3">
<CallInfoButton icon={<Info />} title="Meeting details" />
<CallInfoButton icon={<Group />} title="People" />
<CallInfoButton
icon={<Chat />}
title="Chat with everyone"
/>
</div>
</div>
...
</div>
</StreamTheme>
);
};
export default Meeting;
In our updated meeting page:
- We included
ToggleAudioButton
,ToggleVideoButton
, and otherCallControlButton
components in the footer. - We added a
leaveCall
function that allows users to exit the meeting and redirect them to the meeting end page. - We added the
CallInfoButton
components.
Building the Meeting Popup
The meeting popup will help notify the creator that their meeting is ready. It will also provide the user with a text field where they can copy the meeting link and share it with others.
Create a new file named MeetingPopup.tsx
in the components
directory and add the following code:
import Image from 'next/image';
import { useEffect } from 'react';
import { useCall, useConnectedUser } from '@stream-io/video-react-sdk';
import ButtonWithIcon from './ButtonWithIcon';
import Clipboard from './Clipboard';
import PersonAdd from './icons/PersonAdd';
import Popup from './Popup';
import useLocalStorage from '../hooks/useLocalStorage';
const MeetingPopup = () => {
const user = useConnectedUser();
const call = useCall();
const meetingId = call?.id!;
const [seen, setSeen] = useLocalStorage(`meetingPopupSeen`, {
[meetingId]: false,
});
const email = user?.custom?.email || user?.name || user?.id;
const clipboardValue = window.location.href
.replace('http://', '')
.replace('https://', '')
.replace('/meeting', '');
const onClose = () => {
setSeen({
...seen,
[meetingId]: true,
});
};
useEffect(() => {
setSeen({
...seen,
[meetingId]: seen[meetingId] || false,
});
const setSeenTrue = () => {
if (seen[meetingId]) return;
setSeen({
...seen,
[meetingId]: true,
});
};
return () => {
setSeenTrue();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<Popup
open={!seen[meetingId]}
onClose={onClose}
title={<h2>Your meeting's ready</h2>}
className="bottom-0 -translate-y-22.5 animate-popup"
>
<div className="p-6 pt-0">
<ButtonWithIcon
icon={
<div className="w-6.5 flex items-center justify-start">
<PersonAdd />
</div>
}
rounding="lg"
size="sm"
variant="secondary"
>
Add others
</ButtonWithIcon>
<div className="mt-2 text-dark-gray text-sm font-roboto tracking-loosest">
Or share this meeting link with others you want in the meeting
</div>
<div className="mt-2">
<Clipboard value={clipboardValue} />
</div>
<div className="my-4 flex items-center gap-2">
<Image
width={26}
height={26}
alt="Your meeting is safe"
src="https://www.gstatic.com/meet/security_shield_with_background_2f8144e462c57b3e56354926e0cda615.svg"
/>
<div className="text-xs font-roboto text-meet-gray tracking-wide">
People who use this meeting link must get your permission before
they can join.
</div>
</div>
<div className="text-xs font-roboto text-meet-gray tracking-wide">
Joined as {email}
</div>
</div>
</Popup>
);
};
export default MeetingPopup;
In the code above:
- We use
useConnectedUser
to get the current user's information anduseCall
to access the call instance. - A custom hook
useLocalStorage
tracks whether the meeting creator has closed the popup for the current meeting. - The popup includes a button to add others, a field to copy the meeting link, and information about meeting security.
- The popup is only displayed if the user is the creator of the meeting and hasn't seen it yet.
Next, let’s add it to our meeting page.
In the meeting
directory, update the page.tsx
file with the following code:
...
import {
...
useConnectedUser,
} from '@stream-io/video-react-sdk';
...
import MeetingPopup from '@/components/MeetingPopup';
...
const Meeting = ({ params }: MeetingProps) => {
...
const user = useConnectedUser();
...
const isCreator = call?.state.createdBy?.id === user?.id;
...
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
...
{isCreator && <MeetingPopup />}
<audio
ref={audioRef}
src="..."
/>
</div>
</StreamTheme>
);
};
export default Meeting;
In our updated code, we check if the current user's ID matches the meeting creator's ID. If the user is the creator, we include the MeetingPopup
in the component tree.
Implementing Screen Sharing
Stream’s Video SDK makes it easy to implement screen sharing in our app. We can add this feature by simply using their useScreenShareState
hook.
Let's update our meeting page to add a screen-sharing toggle so users can start or stop sharing their screens.
Open the page.tsx
file in the meeting
folder and update it as follows:
...
const Meeting = ({ params }: MeetingProps) => {
...
const { useCallCallingState, useParticipants, useScreenShareState } =
useCallStateHooks();
const participants = useParticipants();
const { screenShare } = useScreenShareState();
const callingState = useCallCallingState();
...
const toggleScreenShare = async () => {
try {
await screenShare.toggle();
} catch (error) {
console.error(error);
}
};
...
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
...
<div className="absolute left-0 bottom-0 right-0 w-full h-20 bg-meet-black text-white text-center flex items-center justify-between">
...
{/* Meeting Controls */}
<div className="relative flex grow shrink basis-1/4 items-center justify-center px-1.5 gap-3 ml-0">
...
<CallControlButton
onClick={toggleScreenShare}
icon={<PresentToAll />}
title={'Present now'}
/>
...
</div>
...
</div>
...
</div>
</StreamTheme>
);
};
export default Meeting;
In the updated code above:
- We use
useScreenShareState
from the SDK to access the screen-sharing state and control methods. - The
toggleScreenShare
function callsscreenShare.toggle()
to start or stop screen sharing. - We modify the “Present now” button to enable screen sharing, allowing users to initiate the action.
And with that, users should now be able to share their screens.
Adding Real-time Messaging
The next feature we’ll be adding to our app is chat messaging. This feature will allow participants to communicate via text during the meeting.
We'll create a ChatPopup
component that displays a chat window using Stream’s Chat SDK components.
Create a new file named ChatPopup.tsx
in the components
directory and add the following code:
import {
DefaultStreamChatGenerics,
MessageInput,
MessageList,
Channel,
Window,
} from 'stream-chat-react';
import { type Channel as ChannelType } from 'stream-chat';
import Popup from './Popup';
interface ChatPopupProps {
isOpen: boolean;
onClose: () => void;
channel: ChannelType<DefaultStreamChatGenerics>;
}
const ChatPopup = ({ channel, isOpen, onClose }: ChatPopupProps) => {
return (
<Popup
open={isOpen}
onClose={onClose}
title={<h2>In-call messages</h2>}
className="bottom-[5rem] right-4 left-auto h-[calc(100svh-6rem)] animate-none"
>
<div className="px-0 pb-3 pt-0 h-[calc(100%-66px)]">
<Channel channel={channel}>
<Window>
<MessageList disableDateSeparator />
<MessageInput noFiles />
</Window>
</Channel>
</div>
</Popup>
);
};
export default ChatPopup;
In the snippet above:
- The chat window is presented as a popup that participants can toggle from the meeting controls.
- We use the
Channel
component fromstream-chat-react
to handle the messaging channel. MessageList
displays the messages andMessageInput
allows users to send new messages.
Next, let’s integrate the chat into our meeting page.
In the meeting
directory, update the page.tsx
file with the following:
...
import { Channel } from 'stream-chat';
import { DefaultStreamChatGenerics, useChatContext } from 'stream-chat-react';
...
import ChatFilled from '@/components/icons/ChatFilled';
import ChatPopup from '@/components/ChatPopup';
...
const Meeting = ({ params }: MeetingProps) => {
....
const { client: chatClient } = useChatContext();
...
const [chatChannel, setChatChannel] =
useState<Channel<DefaultStreamChatGenerics>>();
const [isChatOpen, setIsChatOpen] = useState(false);
...
useEffect(() => {
const startup = async () => {
if (isUnkownOrIdle) {
router.push(`/${meetingId}`);
} else if (chatClient) {
const channel = chatClient.channel('messaging', meetingId);
setChatChannel(channel);
}
};
startup();
}, [router, meetingId, isUnkownOrIdle, chatClient]);
...
const toggleChatPopup = () => {
setIsChatOpen((prev) => !prev);
};
...
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
...
<div className="absolute left-0 bottom-0 right-0 w-full h-20 bg-meet-black text-white text-center flex items-center justify-between">
...
{/* Meeting Info */}
<div className="hidden sm:flex grow shrink basis-1/4 items-center justify-end mr-3">
...
<CallInfoButton
onClick={toggleChatPopup}
icon={
isChatOpen ? <ChatFilled color="var(--icon-blue)" /> : <Chat />
}
title="Chat with everyone"
/>
</div>
</div>
<ChatPopup
channel={chatChannel!}
isOpen={isChatOpen}
onClose={() => setIsChatOpen(false)}
/>
...
</div>
</StreamTheme>
);
};
export default Meeting;
In the code above:
- We use
useState
to manage the chat channel and the open/closed state of the chat popup. - In the updated
useEffect
hook, we create the chat channel associated with the meeting ID. - The
toggleChatPopup
function manages the visibility of the chat popup. - We updated the “Chat with everyone”
CallInfoButton
for the chat, which changes appearance when the chat is open.
Finally, to ensure users can send messages and read the channel, we need to configure permissions in the Stream dashboard:
- Navigate to the "Roles & Permissions" tab under "Chat messaging."
- Select the "user" role and the "messaging" scope.
- Click the “Edit” button and select the "Create Message," "Read Channel," and "Read Channel Members" permissions.
- Save and confirm the changes.
And with that, we should now have our chat messaging fully integrated!
Recording Meetings
Recording meetings allow participants to save the meeting content for later reference. This feature is handy for presentations, training, or any session participants may want to revisit.
We'll use Stream Video SDK to enable users to start, stop, and view recordings.
Create a RecordingsPopup
component in the components
directory with the following code:
import { MutableRefObject, useEffect, useState } from 'react';
import {
CallRecording,
CallRecordingList,
useCall,
} from '@stream-io/video-react-sdk';
import Popup from './Popup';
import useClickOutside from '../hooks/useClickOutside';
interface RecordingsPopupProps {
isOpen: boolean;
onClose: () => void;
}
const RecordingsPopup = ({ isOpen, onClose }: RecordingsPopupProps) => {
const call = useCall();
const [callRecordings, setCallRecordings] = useState<CallRecording[]>([]);
const [loading, setLoading] = useState(true);
const ref = useClickOutside(() => {
onClose();
}, true) as MutableRefObject<HTMLDivElement>;
useEffect(() => {
const fetchCallRecordings = async () => {
try {
const response = await call?.queryRecordings();
setCallRecordings(response?.recordings || []);
} catch (error) {
console.error(error);
}
setLoading(false);
};
call && isOpen && fetchCallRecordings();
}, [call, isOpen]);
return (
<Popup
ref={ref}
open={isOpen}
className="left-auto right-[0] bottom-[3.25rem] overflow-hidden !bg-container-gray shadow-[0_2px_2px_0_rgba(0,0,0,.14),0_3px_1px_-2px_rgba(0,0,0,.12),0_1px_5px_0_rgba(0,0,0,.2)]"
>
<div className="w-full min-h-[7rem] py-8 px-4">
<CallRecordingList callRecordings={callRecordings} loading={loading} />
</div>
</Popup>
);
};
export default RecordingsPopup;
In the code above:
- Similar to the chat, the recordings are displayed in a popup.
- We use
call.queryRecordings()
to retrieve the list of recordings for the current call. - The
CallRecordingList
component from the SDK displays the available recordings.
Next, let’s integrate the recording controls into the meeting page.
Update the page.tsx
file in the meeting
directory with the following code:
...
import {
...
RecordCallButton,
} from '@stream-io/video-react-sdk';
...
import RecordingsPopup from '@/components/RecordingsPopup';
...
const Meeting = ({ params }: MeetingProps) => {
...
const [isRecordingListOpen, setIsRecordingListOpen] = useState(false);
...
const toggleRecordingsList = () => {
setIsRecordingListOpen((prev) => !prev);
};
...
return (
<StreamTheme className="root-theme">
<div className="relative w-svw h-svh bg-meet-black overflow-hidden">
...
<div className="absolute left-0 bottom-0 right-0 w-full h-20 bg-meet-black text-white text-center flex items-center justify-between">
...
{/* Meeting Controls */}
<div className="relative flex grow shrink basis-1/4 items-center justify-center px-1.5 gap-3 ml-0">
...
<CallControlButton
...
title={'Present now'}
/>
<RecordCallButton />
<div className="hidden sm:block relative">
<CallControlButton
onClick={toggleRecordingsList}
icon={<MoreVert />}
title={'View recording list'}
/>
<RecordingsPopup
isOpen={isRecordingListOpen}
onClose={() => setIsRecordingListOpen(false)}
/>
</div>
<CallControlButton
...
className="leave-call-button"
/>
</div>
...
</div>
...
</div>
</StreamTheme>
);
};
export default Meeting;
In the code above:
- We use
useState
to manage the open/closed state of the recordings popup. - The
toggleRecordingsList
function controls the visibility of the recordings list. - We include the
RecordCallButton
provided by the SDK and a button to open the recordings list. - We update our styles to ensure the new controls fit seamlessly into the UI.
Finally, let’s adjust the styles for the recording button to match our app's theme:
...
@layer components {
...
.root-theme .str-video__composite-button .str-video__composite-button__button-group {
@apply bg-dark-gray w-[2.5rem] h-[2.5rem];
&:hover {
@apply bg-[#444649];
}
& button {
@apply w-[2.5rem] h-[2.5rem];
}
}
.root-theme .str-video__composite-button__button-group.str-video__composite-button__button-group--active-secondary {
@apply bg-meet-red;
&:hover {
@apply bg-hover-red;
}
}
...
}
...
And that’s it! With the recording feature integrated, our Google Meet clone is now complete.
Conclusion
In this tutorial series, we have designed a complete video-calling application that mirrors Google Meet. We added features like screen sharing, real-time messaging, and meeting recordings to provide users with a professional video conferencing experience.
While we've covered a lot, there's always room to improve the app further using Stream’s Video SDK. For example, you could add live transcriptions to improve the app's accessibility. Integrating reactions and emojis can also be a great addition since it allows participants to send quick feedback without interrupting the current speaker.
Don't hesitate to look at the SDK's documentation and experiment further with the application.
Also, feel free to check out the GitHub repository for this project and explore the code.