Building a Google Meet Clone with Next.js and TailwindCSS — Part Two

Building a Google Meet Clone with Next.js and TailwindCSS — Part Two

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

Preview of the Call controls component.

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 the ParticipantView 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.
  • 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 the SpeakerLayout or GridLayout 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
  • Theme Application:
    • We wrap the component with StreamTheme to apply consistent theming across the meeting page.

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 parameter isSpeakerLayout 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 the deviceSelectors and icons.
  • 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, our ToggleAudioButton is composed of a CallControlButton wrapped around the ToggleButtonContainer.
  • The ToggleButtonContainer takes in a React fragment containing AudioInputDeviceSelector and AudioOutputDeviceSelector as the deviceSelector 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 other CallControlButton 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&apos;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 and useCall 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 calls screenShare.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 from stream-chat-react to handle the messaging channel.
  • MessageList displays the messages and MessageInput 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 everyoneCallInfoButton 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:

  1. Navigate to the "Roles & Permissions" tab under "Chat messaging."
  2. Select the "user" role and the "messaging" scope.
  3. Click the “Edit” button and select the "Create Message," "Read Channel," and "Read Channel Members" permissions.
  4. 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.