Building a Clubhouse clone with Svelte and 100ms

Building a Clubhouse clone with Svelte and 100ms

In this article, we will go through the process of building a Clubhouse clone with 100ms and Svelte. Clubhouse is a popular app that enables people to speak together in audio rooms over the internet.

This content was originally published - HERE

What is Svelte?

Svelte is a new framework that institutes a whole new way for frameworks to interact with the DOM. It doesn't use a VDOM but surgically updates the DOM based on what you want to display.

We also have step-by-step guides to build Clubhouse like app with different technologies

It’s also lightweight and, thus, faster because it doesn't ship svelte to the frontend. Instead, it ships code that performs the update.

What is 100ms?

100ms provides video conferencing infrastructure designed to help businesses build powerful video applications in hours. Its SDK is tailored to suit numerous use cases like game streaming, virtual events, audio rooms, classrooms, and much more. It abstracts the complexities involved in building these apps and reduces development time drastically.

To top it all off, 100ms has been created and is managed by the team who created live infrastructure at Disney and Facebook (Meta).

Prerequisites

Explanation of 100ms terms

There are a couple of terms used by 100ms that we need to get familiar with to understand this article:

  • Room: A room is the basic object that 100ms SDKs return on a successful connection. This contains references to peers, tracks and everything you need to render a live audio/visual app.
  • Peer: A peer is the object returned by 100ms SDKs that contain all information about a user: name, role, video track, etc.
  • Track: A track represents either the audio or video that a peer is publishing.
  • Role: A role defines who a peer can see/hear, the quality at which they publish their video, whether they have permissions to publish video/screen share, mute someone, and/or change someone's role.

You can read about the other terms here

Steps around 100ms

  • Setting up 100ms
  • Creating an account
  • Creating Roles & Rooms

Steps around setting up the Frontend

  • Add 100ms to the Svelte app
  • Join a Room
  • Leave a Room
  • Mute/Unmute a Peer
  • Chat in a Room

Setting up 100ms

-Log in to your 100ms account. On the dashboard, click the 'Add a New App' Button.

1.png

  • Choose a subdomain for your app.

2.png

  • Choose the template for the app. Since we're building a Clubhouse Clone, 'Audio Rooms' will give us the right setup out-of-the-box. Select that option.

3.png

  • You should then see this appear on your screen.

4.png

Click on any of the Join as buttons to test out the platform.

To save time, head to the Developer tab of the dashboard, copy the token endpoint and store it in a safe place. Additionally, head to the Rooms tab, and store the Room ID of the room we just created.

Setting up the Frontend

To get started, clone this starter pack. It contains the main setup needed for the app, like SCSS and page routing as well as its components. After cloning, run

yarn

to install all dependencies of the starter pack.

Run

yarn dev

to start the project. You should see the following:

5.png

Under src/services/hms.js, we have set up the basic 100ms functions. These functions enable us to connect our components to 100ms.

Head into the App.svelte file in src and replace its content with:

<script>
  import router from "page";
  import Home from "./pages/home.svelte";
  import Room from "./pages/room.svelte";
//NEW LINE HERE
  import { onMount } from "svelte";
  import { hmsStore } from "./services/hms";
  import { selectIsConnectedToRoom } from "@100mslive/hms-video-store";
//NEW LINE ENDS

  let page;

  router("/", () => (page = Home));

  router("/room", () => (page = Room));

  router.start();
//NEW LINE HERE
  const onRoomStateChange = (connected) => {
    if (connected) router.redirect("/room");
    else router.redirect("/");
  };

  onMount(async () => {
    hmsStore.subscribe(onRoomStateChange, selectIsConnectedToRoom);
  });
//NEW LINE ENDS
</script>

<svelte:component this={page} />

Going from the top, 3 new variables are imported:

  • onMount: This is a function by Svelte that runs after a component mounts (like componentDidMount in React). You mainly use it to subscribe to listeners or make requests to API endpoints.
  • hmsStore: This contains the complete state of the room at any given time. It includes participant details, messages, and track states.
  • selectIsConnectedToRoom: The 100ms package provides a number of selectors that we can use to extract information from the store. In this case, we're extracting a boolean value that tells you if you're connected to a room or not.

You can read about other selectors here.

In the onMount function, we set a listener that calls onRoomStateChange whenever the connection state changes. The onRoomStateChange reacts to this by redirecting you to the appropriate page based on its input.

Head to the home.svelte file and replace its contents with:

<script>
  import { hmsActions } from "./../services/hms";
  import { getToken } from "./../utils/utils";
  let userName = "";
  let role = "";

  const submitForm = async () => {
    if (!userName || !role) return;
    try {
      const authToken = await getToken(role, userName);
      const config = {
        userName,
        authToken,
        settings: {
          isAudioMuted: true,
          isVideoMuted: false,
        },
        rememberDeviceSelection: true,
      };
      hmsActions.join(config);
    } catch (error) {
      console.log("Token API Error", error);
    }
  };
</script>

<main>
  <form>
    <header>Join Room</header>
    <label for="username">
      Username
      <input
        bind:value={userName}
        id="username"
        type="text"
        placeholder="Username"
      />
    </label>
    <label>
      Role
      <select bind:value={role} name="role">
        <option value="speaker">Speaker</option>
        <option value="listener">Listener</option>
        <option value="moderator">Moderator</option>
      </select>
    </label>
    <button on:click|preventDefault={submitForm}> Join </button>
  </form>
</main>

Here we import:

  • hmsActions: This is used to perform any action such as joining, muting, and sending a message.
  • getToken: It helps us generate a token that we need to join any 100ms room.

We also have a function, submitForm, that couples the config variable and adds us to the room using hmsAction.

In the markup, you'll notice we have bind: in the input. This is called a directive and Svelte gives us numerous directives to make our lives easier.

The bind:value directive links the value of the input to the specified variable.

In your case, this variable is the username variable. You also use it in the select element. The on:click directive, on the other hand, attaches the specified function as the handler to the click event on that button.

Svelte also gives us modifiers like |preventDefault that customizes the directive to our taste. In our case, |preventDefault calls the event.preventDefault function before running the handler.

You'll also notice that we haven't implemented the getToken function, so let's get to it. Create a utils.js file in the directory src/utils and paste the following:

const TOKEN_ENDPOINT = process.env.TOKEN_ENDPOINT;
const ROOM_ID = process.env.ROOM_ID;

export const getToken = async (userRole, userName) => {
  const role = userRole.toLowerCase();
  const user_id = userName;
  const room_id = ROOM_ID;
  let payload = {
    user_id,
    role,
    room_id,
  };
  let url = `${TOKEN_ENDPOINT}api/token`;
  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify(payload),
  });
  let resp = await response.json();
  return resp.token;
};

First, you extract the environment variables from process.env. Then, make a call to the endpoint provided to you by 100ms. This endpoint responds with the needed token.

But we haven't set up our environmental variables. We can do this easily by installing some packages. Run

yarn -D dotenv @rollup/plugin-replace

to get them installed. Then open the rollup.config.js in the root of the folder and paste the following:

//NEW LINE STARTS
import replace from "@rollup/plugin-replace";
import { config } from "dotenv";
//NEW LINE ENDS

const production = !process.env.ROLLUP_WATCH;

//CODE OMITTED FOR BREVITY

export default {
  input: "src/main.js",
  output: {
    sourcemap: true,
    format: "iife",
    name: "app",
    file: "public/build/bundle.js",
  },
  plugins: [
      //NEW LINE STARTS
    replace({
      "process.env.NODE_ENV": JSON.stringify("production"),
      "process.env.TOKEN_ENDPOINT": JSON.stringify(
        config().parsed?.TOKEN_ENDPOINT || process.env.TOKEN_ENDPOINT
      ),
      "process.env.ROOM_ID": JSON.stringify(
        config().parsed?.ROOM_ID || process.env.ROOM_ID
      ),
    }),
      //NEW LINE ENDS
    svelte({
      preprocess: preprocess(),
      compilerOptions: {
        dev: !production,
      },
    }),

Our getToken function should be up and running now. Next, replace the code in room.svelte with the following:

<script>
  import page from "page";
  import Peer from "./../components/peer.svelte";
  import { hmsActions, hmsStore } from "./../services/hms";
  import { selectPeers,selectLocalPeerRole,
    selectIsLocalAudioEnabled, } from "@100mslive/hms-video-store";
  import { onMount, onDestroy } from "svelte";
  import { PeerStore } from "./../stores";

  let peers = [];
  let localPeerRole = "";
  let audioEnabled = null;

  const handlePeers = (iPeers) => {
    let res = hmsStore.getState(selectLocalPeerRole);
    localPeerRole = res ? res.name : "";
    audioEnabled = hmsStore.getState(selectIsLocalAudioEnabled);
    PeerStore.set(iPeers);
  };

  const handleMute = async () => {
    await hmsActions.setLocalAudioEnabled(!audioEnabled);
    audioEnabled = hmsStore.getState(selectIsLocalAudioEnabled);
  };

  onMount(async () => {
    hmsStore.subscribe(handlePeers, selectPeers);
  });

  const leaveRoom = () => hmsActions.leave();

  onDestroy(leaveRoom);

  $: peers = $PeerStore;
</script>

<main>
  <h1>Welcome To The Room</h1>

  <section class="peers">
    {#each peers as peer (peer.id)}
      <Peer {localPeerRole} {peer} />
    {/each}
  </section>
  <div class="buttons">
    {#if localPeerRole != "listener"}
      <button on:click={handleMute} class="mute"
        >{audioEnabled ? "Mute" : "Unmute"}</button
      >
    {/if}
    <button on:click={leaveRoom} class="leave">Leave Room</button>
  </div>
</main>

This page houses the most important features of our app. First, we import the required variables. Some of these are:

  • onDestroy: This function is similar to onMount except it is called immediately before the component is unmounted.
  • PeerStore: This is a store that would keep track of the current peers in the room.

The handlePeers function does three things:

  • It stores the role of the local peer in the localPeerRole variable.
  • It sets the audio state of the local peer in the audioEnabled variable.
  • It stores the current peers in the room within the PeerStore store.

The handleMute function simply toggles the audio state of the local peer. A leaveRoom is called when the component is to be unmounted or when the Leave Room button is clicked.

The $: syntax helps us create reactive statements. These statements run immediately before the component updates, whenever the values that they depend on have changed.

We have 2 new syntaxes in our markup:

  • {#each peers as peer (peer.id)}:This helps us map out each peer in the peers array using the peer's ID as a key.
  • {#if localPeerRole != "listener"}:This renders the component between the if block, if the condition is true. Therefore, it renders the Mute button if the local peer is not a listener.

On to the last component, peer.svelte. For the last time, copy the code below into the file:

<script>
  import {
    selectIsPeerAudioEnabled,
  } from "@100mslive/hms-video-store";
  import { onMount } from "svelte";
  import { hmsActions, hmsStore } from "../services/hms";
  export let peer = null;
  export let localPeerRole = "";

  let isContextOpen = false;
  let firstCharInName = "";
  let isPeerMuted = false;

  const togglePeerAudio = () => {
    hmsActions.setRemoteTrackEnabled(peer.audioTrack, isPeerMuted);
  };

  const changeRole = (role) => {
    hmsActions.changeRole(peer.id, role, true);
  };

  onMount(async () => {
    hmsStore.subscribe((isPeerAudioEnabled) => {
      isPeerMuted = !isPeerAudioEnabled;
    }, selectIsPeerAudioEnabled(peer?.id));
  });

  $: firstCharInName = peer ? peer.name.split(" ")[0][0].toUpperCase() : "";
</script>

<div class="peer">
  <div on:click={() => (isContextOpen = !isContextOpen)} class="content">
    <div class="image">
      <p>{firstCharInName}</p>
    </div>
    <p>{peer ? peer.name : ""}{peer && peer.isLocal ? " (You)" : ""}</p>
  </div>
  {#if localPeerRole == "moderator" && !peer.isLocal}
    <div class="context" class:open={isContextOpen}>
      <button on:click={togglePeerAudio}
        >{isPeerMuted ? "Unmute" : "Mute"}</button
      >
      <button on:click={() => changeRole("speaker")}>Make Speaker</button>
      <button on:click={() => changeRole("listener")}>Make Listener</button>
    </div>
  {/if}
</div>

Once again, all needed variables are imported. You are expecting 2 props: peer and localPeerRole.

2 functions are declared: togglePeerAudio and changeRole. They do exactly what their names describe. In the onMount function, a handler is added to update the isPeerMuted state of a peer.

Each peer component has a context menu that has options for muting the peer or changing their role. But this menu is only available to moderators as only they should have such permissions.

At this point, we are done.

You can run

yarn dev

in the terminal to see the application.

6.gif