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
- Clubhouse clone with 100ms iOS SDK
- Clubhouse clone with 100ms React SDK
- Clubhouse clone with 100ms Flutter SDK
- Clubhouse clone with 100ms Javascript SDK
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
- A fair understanding of Javascript
- A 100ms account; if you don't have one, you can create your account here
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.
- Choose a subdomain for your app.
- 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.
- You should then see this appear on your screen.
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:
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 thepeers
array using the peer's ID as a key.{#if localPeerRole != "listener"}
:This renders the component between theif
block, if the condition is true. Therefore, it renders theMute
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.