Building a Group Video Chat App with WebRTC, PHP Laravel & JavaScript

Building a Group Video Chat App with WebRTC, PHP Laravel & JavaScript

Metered Team

Table of Contents

In this tutorial we will be a highly scalable group video conferencing app using WebRTC, PHP Laravel and Javascript

This video calling application will be able to handle hundreds of participants in a group video call.

The source code this application is available on Github.

Video Chat Application in PHP Laravel

Overview

We will discuss the technologies required to build the group video calling, learn about WebRTC and it's limitations in developing a group video call with large number of participants and how we can bypass the limitations.

Then we will build a highly scalable group video conferencing application with WebRTC PHP+Laravel backend and Livewire.

Why WebRTC?

WebRTC is a collection of protocols and technologies that allows real-time communication.

It providers a way to have plugin-free real time audio and video communication in very low latency right from the browser.

Hence we will use WebRTC to create a seamless plugin free video calling experience for the user, and allow the user to have a video call right from their browser without installing any software or plugins.

WebRTC was designed to have peer-to-peer communication between participants in a WebRTC call.

But WebRTC calls are not entirely peer-to-peer, centralized server is required for the peers to discover each other, and if the peers are behind a NAT, then STUN/TURN servers are required for NAT Traversal.

In the next section we will briefly touch over these technologies.

Building blocks of WebRTC

WebRTC has many components, and many of those components are handled automatically by the underlying browser.

But we will discuss about the two components that require a centralized server to operate, and they are:

  1. WebRTC Signaling Server
  2. STUN/TURN Server

WebRTC Signaling Server

A WebRTC Signaling server is required for the peers to discover each other, and exchange the SDP information and ICE Candidates to establish a peer to peer session.

It is left to the user to build their own signaling server. The signaling server is built typically using WebSockets.

STUN/TURN Server

The STUN/TURN Server is required for NAT Traversal. Most consumer devices these days, be it your laptop or smartphone is typically behind a NAT.

To connect to the internet a public IP address is required, the public IP address is assigned to you by your internet service provider.

For each device you own, you would require a public IP address, but there are more devices currently connected to the internet than the available IPv4 addresses.

To solve this problem NAT was invented, NAT allows multiple devices to share one public IP address.

NAT or Network Address translation is typically done by your router or modem provided by your ISP.

In WebRTC we establish a direct connection between the two devices, but both the devices are typically behind a NAT, and we need a mechanism to traverse the NAT and connect directly to those devices.

For this we use the STUN and TURN servers.

If you are looking for STUN and TURN servers then you can consider Metered TURN server

Here are some of the features of Metered TURN servers

Metered Global TURN servers

  1. Global Geo-Location targeting: Automatically directs traffic to the nearest servers, for lowest possible latency and highest quality performance.
  2. Servers in 12 Regions of the world: Metered TURN has servers in 12 regions of the world including: Toronto, Miami, San Francisco, Amsterdam, London, Frankfurt, Bangalore, Singapore,Sydney
  3. Low Latency: less than 50 ms latency, anywhere across the world.
  4. Cost-Effective: pay-as-you-go pricing with bandwidth and volume discounts available.
  5. Easy Administration: Get usage logs, emails when accounts reach threshold limits, billing records and email and phone support.
  6. Standards Compliant: Conforms to RFCs 5389, 5769, 5780, 5766, 6062, 6156, 5245, 5768, 6336, 6544, 5928 over UDP, TCP, TLS, and DTLS.
  7. Multi‑Tenancy: Create multiple credentials and separate the usage by customer, or different apps. Get Usage logs, billing records and threshold alerts.
  8. Reliability: 99.999% Uptime with SLA.
  9. Enterprise Scale: : With no limit on concurrent traffic or total traffic. Metered TURN Servers provide Enterprise Scalability
  10. 50 GB/mo Free: Get 50 GB every month free TURN server usage with the Free Plan

You can sign up here: Metered TURN server Sign Up

Limitations and Scaling Considerations

As we have discussed WebRTC works in a peer to peer manner, where each participant in a WebRTC meeting connects to every other peer in the meeting.

This topology is good for one to one calls, or even for calls with 3 participants, but it results is too much load on upload bandwidth and CPU for calls with more than 3 participants.

Because, if there are for e.g. 10 people in a call, then each participant would have to upload their video+audio to 9 other participants simultaneously, which puts tremendous load on the CPU and bandwidth.

To scale a WebRTC call, you require a WebRTC Server, what WebRTC Server does, is aggregates the audio+video stream from all the participants and distributes it to others in the call.

So if we consider our previous example with 10 participants if we use a WebRTC server, then the user would have to upload their audio+video to the WebRTC Server.

Instead of uploading the video to 9 other participants there will be only one upload, which is to the WebRTC Server and it reduces the load on CPU and bandwidth significantly.

This makes it possible to easily scale the WebRTC call to hundreds of participants.

In our tutorial we will use the WebRTC Server from Metered Video.

Building Video Chat Application

Let's start building a highly scalable group video calling application with WebRTC and PHP Laravel.

We will first start with building the backend server of the application, the backend server will have API to create a meeting room, where other participants can join.

We will also create an API to validate the meeting room, so when a user wants to join a meeting room, we can check if the room exists or not.

In the PHP backend we will call the Metered Video SDK to create a meeting and also to validate the meeting room.

Pre-requisite

You will need basic knowledge of PHP, HTML and JavaScript to develop this application.

You will also need a Metered Video account, if you don't have an account then signup for a free account at metered.ca and click "Signup and Start Building"

After  you have created your account, come back here and follow along.

Metered Domain and Secret Key

Let's obtain the Metered Domain and Secret Key we will need them later in the project.

To get your Metered Domain and Secret Key login to your Metered Dashboard, and the click on "Developers" in the sidebar.

Metered Domain and Secret Key

Note down the Metered Domain and Secret Key, we will use them later in the tutorial.

Scaffolding the project

We will scaffold the php Laravel project by running the command

laravel new webrtc-php-laravel

And start the server using

php artisan serve
PHP Laravel

Once we have scaffolded the project, let start building our video chat application.

Building the Join Meeting Page

We will update the home page view to create the interface to join or create a new meeting.

Let update the homepage view under resources/views/welcome.blade.php and build the UI to create a new meeting or join an existing meeting.

PHP Laravel Video Chat Application Join Page

Let's scaffold the basic UI, we will use tailwindcss for our styling, here is the basic UI

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">


        <style>
            body {
                font-family: 'Nunito', sans-serif;
            }
        </style>

        @vite(['resources/css/app.css', 'resources/js/app.js'])

    </head>
    <body class="antialiased">
        
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="py-4">
                <h1 class="text-2xl">Video Chat Application</h1>
            </div>

            <div class="max-w-2xl">
                <div>                
                    <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
                    <div class="mt-1">
                        <input type="text" name="name" id="name" class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md" placeholder="John Smith">
                    </div>
                </div>
            </div>
            <div class="max-w-2xl">
                <div class="grid md:grid-cols-3 grid-cols-1 mt-4">
                    <div class="col-span-2">
                        <div class="mt-1 flex rounded-md shadow-sm">
                          <div class="relative flex items-stretch flex-grow focus-within:z-10">
                            <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
                              <!-- Heroicon name: solid/users -->
                              <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
                              </svg>
                            </div>
                            <input type="text" name="meetingId" id="meetingId" class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300" placeholder="Meeting ID">
                          </div>
                          <button type="button" class="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
                            <span>Join Meeting</span>
                          </button>
                        </div>
                      </div>
              
                      <div>
                        <span class="text-xs uppercase font-bold text-gray-400 px-1">OR</span>
                        <button type="button" class="mt-1 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Create New Meeting</button>
                    </div>
                </div>

            </div>
  
        </div> 
    </body>
</html>

Now let's build the logic to handle the Join Meeting page.

We will create two routes, one to handle Join Meeting and the second to handle Create New Meeting.

Under the  Join Meeting route we will call the Metered Video SDK, to validate the meetingID provided by the user, if the meetingID is valid then we will redirect the user to the Meeting Area, otherwise we will show an error.

For Create Meeting route we will call the Metered Video SDK's Create Room API, and then redirect the user to the Meeting Area.

Create Meeting

Let's wrap the "Create Meeting" button in a form tag, and with action route to createMeeting

<div>
<span class="text-xs uppercase font-bold text-gray-400 px-1">OR</span>
<form method="post" action="{{ route('createMeeting') }}">
    {{ csrf_field() }}
    <button type="submit" class="mt-1 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Create New Meeting</button>
</form>
</div>

Let's create a controller to handle all our meeting related methods.

php artisan make:controller MeetingController
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class MeetingController extends Controller
{
    //

    public function createMeeting(Request $request) {
        // Contain the logic to create a new meeting
    }
}
MeetingController.php

We will update our web.php file as well and include the createMeeting route and call the createMeeting method of our MeetingController in the route.

<?php

use Illuminate\Support\Facades\Route;

use App\Http\Controllers\MeetingController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Route::post("/createMeeting", [MeetingController::class, 'createMeeting'])->name("createMeeting");
web.php

Remember earlier in this tutorial we have noted down the Metered Domain and the Metered Secret Key, now we will be using them.

In the .env file add the METERED_DOMAIN and the METERED_SECRET_KEY

METERED_DOMAIN="yourappname.metered.live"
METERED_SECRET_KEY="hoHqpIkn8MqZvwHReHt8tm_6K0SRMgg6vHwPrBoKkUhz"

Now, let's get back to our MeetingController.php and call the Create Room API

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;



class MeetingController extends Controller
{
    //

    public function createMeeting(Request $request) {
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');
        
        Log::info("https://{$METERED_DOMAIN}/api/v1/room?secretKey={$METERED_SECRET_KEY}");

        // Contain the logic to create a new meeting
        $response = Http::post("https://{$METERED_DOMAIN}/api/v1/room?secretKey={$METERED_SECRET_KEY}", [
            'autoJoin' => true
        ]);

        $roomName = $response->json("roomName");
        
        return redirect('/'); // We will update this soon.
    }
}

In the above code we are calling the "Create Room" REST API of Metered Video SDK to create a new meeting.

Join Existing Meeting

Now let handle the logic to Join Meeting button. Here the user will enter the Meeting ID, and we will validate the Meeting ID.

If the Meeting ID is valid then we will redirect the user to the Meeting page, if the Meeting ID is invalid then we will show an error.

Open the MeetingController.php file and create a method called validateMeeting, this method will call the Get Room API of the Metered Video SDK, and check if a Meeting Room specified by the user exists or not.

    public function validateMeeting(Request $request) {
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');

        $meetingId = $request->input('meetingId');

        // Contains logic to validate existing meeting
        $response = Http::get("https://{$METERED_DOMAIN}/api/v1/room/{$meetingId}?secretKey={$METERED_SECRET_KEY}");

        if ($response->status() === 200)  {
            return redirect("/"); // We will update this soon
        } else {
            return redirect("/?error=Invalid Meeting ID");
        }
    }

Open web.php and create a /validateMeeting route, and call the validateMeeting method of the MeetingController.

Route::post("/validateMeeting", [MeetingController::class, 'validateMeeting'])->name("validateMeeting");

Now open the welcome.blade.php and wrap the Join Meeting button and input tag into a form field and call the validateMeeting route.

<form method="post" action="{{ route('validateMeeting') }}">
  {{ csrf_field() }}
<div class="mt-1 flex rounded-md shadow-sm">
  <div class="relative flex items-stretch flex-grow focus-within:z-10">
    <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">

      <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
        <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
      </svg>
    </div>
    <input type="text" name="meetingId" id="meetingId" class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300" placeholder="Meeting ID">
  </div>
  <button type="submit" class="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
    <span>Join Meeting</span>
  </button>
</div>
</form>

Building the Meeting Page

Then we will redirect the user to the Meeting Page. In the Meeting page we will first show the user Meeting Lobby.

In the Meeting Lobby the user can adjust their camera, speaker and microphone and join the meeting.

We will create a view called as meeting.blade.php in this view we will create the UI for the Meeting

Go to resources/views and create a file called meeting.blade.php for and add the following contents to the file

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">


        <style>
            body {
                font-family: 'Nunito', sans-serif;
            }
        </style>

        <script src="https://cdn.metered.ca/sdk/video/1.4.5/sdk.min.js"></script>

        @vite(['resources/css/app.css', 'resources/js/app.js'])

    </head>
    <body class="antialiased">
        
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="py-4">
                <h1 class="text-2xl">Meeting Lobby</h1>
            </div>


            <div class="max-w-2xl">


            </div>
  
        </div> 
    </body>
</html>

We have included the Metered Video SDK in the head of the file

<script src="https://cdn.metered.ca/sdk/video/1.4.5/sdk.min.js"></script>

Now open the file routes/web.php and create the route for the lobby.

Route::get("/meeting/{meetingId}", function() {
    return view('meeting');
});

Our web.php file looks like this

<?php

use Illuminate\Support\Facades\Route;

use App\Http\Controllers\MeetingController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/

Route::get('/', function () {
    return view('welcome');
});

Route::post("/createMeeting", [MeetingController::class, 'createMeeting'])->name("createMeeting");

Route::post("/validateMeeting", [MeetingController::class, 'validateMeeting'])->name("validateMeeting");

Route::get("/meeting/{meetingId}", function() {
    return view('meeting');
});
web.php

Let open the MeetingController.php file and redirect the user to /meeting/{meetingId} after successfully creating or joining a meeting.

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;




class MeetingController extends Controller
{

    public function createMeeting(Request $request) {
        
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');
    

        // Contain the logic to create a new meeting
        $response = Http::post("https://{$METERED_DOMAIN}/api/v1/room?secretKey={$METERED_SECRET_KEY}", [
            'autoJoin' => true
        ]);

        $roomName = $response->json("roomName");
        
        return redirect("/meeting/{$roomName}"); // We will update this soon.
    }

    public function validateMeeting(Request $request) {
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');

        $meetingId = $request->input('meetingId');

        // Contains logic to validate existing meeting
        $response = Http::get("https://{$METERED_DOMAIN}/api/v1/room/{$meetingId}?secretKey={$METERED_SECRET_KEY}");

        $roomName = $response->json("roomName");

        
        if ($response->status() === 200)  {
            return redirect("/meeting/{$roomName}"); // We will update this soon
        } else {
            return redirect("/?error=Invalid Meeting ID");
        }
    }
}
MeetingController.php

Let's update the meeting.blade.php file to build the basic UI of the lobby

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">


        <style>
            body {
                font-family: 'Nunito', sans-serif;
            }
        </style>

        <script src="https://cdn.metered.ca/sdk/video/1.4.5/sdk.min.js"></script>

        @vite(['resources/css/app.css', 'resources/js/app.js'])

    </head>
    <body class="antialiased">
        
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="py-4">
                <h1 class="text-2xl">Meeting Lobby</h1>
            </div>


            <div class="max-w-2xl">

                <video id='localVideo' class="w-full" autoplay muted></video>

                <div class="flex space-x-4 mb-4 justify-center">

                    <button id='toggleMicrophone' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
                    </button>

                    <button id='toggleCamera' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>                    </button>
                    </button>

                </div>
                <div class="flex space-x-2">
                    <input type="text"  placeholder="Name"/>

                    <label>
                        Camera:
                        <select id='cameraSelectBox'>
                        </select>
                    </label>

                    <label>
                        Microphone:
                        <select id='microphoneSelectBox'>
                        </select>
                    </label>

                    <button id='joinMeetingBtn' class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                        Join Meeting
                    </button>
                </div>

            </div>
  
        </div> 
    </body>
</html>
meeting.blade.php
PHP Laravel Video Chat App Lobby

Building the JavaScript Code

We will use vanilla javascript with some jquery to build the front-end of our application.

Initializing the Metered Meeting Object

Open resources/js/app.js and create the meeting object

const meeting = new Metered.Meeting();

Populating available Cameras and Microphone

We will call the listVideoInputDevices method to get a list of available cameras and listAudioInputDevices method to get the list of available microphones in the system.

We will call these methods and populate the select box

import './bootstrap';
import jquery from 'jquery';

let meetingJoined = false;
const meeting = new Metered.Meeting();

async function initializeView() {
    /**
     * Populating the cameras
     */
     const videoInputDevices = await meeting.listVideoInputDevices();
     const videoOptions = [];
     for (let item of videoInputDevices) {
        videoOptions.push(
            `<option value="${item.deviceId}">${item.label}</option>`
        )
     }
    jquery("#cameraSelectBox").html(videoOptions.join(""));

    /**
     * Populating Microphones
     */
    const audioInputDevices = await meeting.listAudioInputDevices();
    const audioOptions = [];
    for (let item of audioInputDevices) {
        audioOptions.push(
            `<option value="${item.deviceId}">${item.label}</option>`
        )
    }
    jquery("#microphoneSelectBox").html(audioOptions.join(""));
}

initializeView();
app.js

Handling Waiting Area Share Camera/Microphone

We have created two buttons, one for camera and another one for microphone. When the user clicks on these buttons we will share the camera and when then user clicks on the microphone button we will share the microphone, after the user joins the meeting.

To build this logic we will add click event listener to these buttons, and store the state in a variable, for camera we will create a variable called as cameraOn and for microphone we will create a variable called as micOn

In case of camera we also want to show the camera video preview to the user, for that we will store the video stream is localVideoStream variable and display it in a video tag.

    /**
     * Mute/Unmute Camera and Microphone
     */
    let micOn = false;
    jquery("#waitingAreaToggleMicrophone").on("click", function() {
        if (micOn) {
            micOn = false;
            jquery("#waitingAreaToggleMicrophone").removeClass("bg-gray-500");
            jquery("#waitingAreaToggleMicrophone").addClass("bg-gray-400");
        } else {
            micOn = true;
            jquery("#waitingAreaToggleMicrophone").removeClass("bg-gray-400");
            jquery("#waitingAreaToggleMicrophone").addClass("bg-gray-500");
        }
    });

    let cameraOn = false;
    let localVideoStream = null;
    jquery("#waitingAreaToggleCamera").on("click", async function() {
        if (cameraOn) {
            cameraOn = false;
            jquery("#waitingAreaToggleCamera").removeClass("bg-gray-500");
            jquery("#waitingAreaToggleCamera").addClass("bg-gray-400");
            const tracks = localVideoStream.getTracks();
            tracks.forEach(function (track) {
              track.stop();
            });
            localVideoStream = null;
            jquery("#waitingAreaLocalVideo")[0].srcObject = null;
        } else {
            cameraOn = true;
            jquery("#waitingAreaToggleCamera").removeClass("bg-gray-400");
            jquery("#waitingAreaToggleCamera").addClass("bg-gray-500");
            localVideoStream = await meeting.getLocalVideoStream();
            jquery("#waitingAreaLocalVideo")[0].srcObject = localVideoStream;
            cameraOn = true;
        }
    });

After the user joins the meeting we will check the state of these variables. If the cameraOn variable is set to true then we will share the user's camera and if micOn variable is set to true then we will share user's microphone.

Handling Device Change

Change the Microphone or Camera selected by the user, we will add a onChange event listener on the camera and microphone select boxes that we had populated and call the chooseVideoInputDevice(deviceId) if camera was changed and chooseAudioInputDevice(deviceId) method if microphone was changed.

    /**
     * Adding Event Handlers
     */
    jquery("#cameraSelectBox").on("change", async function() {
        const deviceId = jquery("#cameraSelectBox").val();
        await meeting.chooseVideoInputDevice(deviceId);
        if (cameraOn) {
            localVideoStream = await meeting.getLocalVideoStream();
            jquery("#waitingAreaLocalVideo")[0].srcObject = localVideoStream;
        }
    });

    jquery("#microphoneSelectBox").on("change", async function() {
        const deviceId = jquery("#microphoneSelectBox").val();
        await meeting.chooseAudioInputDevice(deviceId);
    });

If camera is changed we also need to update the localVideoStream variable to update the preview of the currently shared camera.

Implementing Join Meeting

We will add an click event listener to the "Join Meeting" button, when the button is clicked, we will call the join(options) method of the Metered Video SDK.

After the Join is successful we will hide the "Waiting Area" and Show the Meeting View.

We will also check if the camera button and microphone button is clicked on the waiting area, if yes then after joining the meeting we will share the camera and microphone.

let meetingInfo = {};
jquery("#joinMeetingBtn").on("click", async function () {
    var username = jquery("#username").val();
    if (!username) {
      return alert("Please enter a username");
    }
  
    try {
      meetingInfo = await meeting.join({
        roomURL: `${window.METERED_DOMAIN}/${window.MEETING_ID}`,
        name: username,
      });
      console.log("Meeting joined", meetingInfo);
      jquery("#waitingArea").addClass("hidden");
      jquery("#meetingView").removeClass("hidden");
      jquery("#meetingAreaUsername").text(username);

      /**
       * If camera button is clicked on the meeting view
       * then sharing the camera after joining the meeting.
       */
      if (cameraOn) {
        await meeting.startVideo();
      }
      
      /**
       * Microphone button is clicked on the meeting view then
       * sharing the microphone after joining the meeting
       */
      if (microphoneOn) {
        await meeting.startAudio();
      }
      
    } catch (ex) {
      console.log("Error occurred when joining the meeting", ex);
    }
  });

Scaffolding the Meeting View

Now we will build the meeting view, the meeting view will show other participants video and audio.

We will also show preview of our own video stream if we are sharing, and will have controls to share camera, microphone and screen.

        <div id='meetingView' class="hidden flex w-screen h-screen space-x-4 p-10">

            <div id="activeSpeakerContainer" class=" bg-gray-900 rounded-3xl flex-1 flex relative">
                <video id="activeSpeakerVideo" src="" autoplay class=" object-contain w-full rounded-t-3xl"></video>
                <div id="activeSpeakerUsername" class="hidden absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                    
                </div>
            </div>  

            <div id="remoteParticipantContainer" class="flex flex-col space-y-4">
                <div id="localParticiapntContainer" class="w-48 h-48 rounded-3xl bg-gray-900 relative">
                    <video id="localVideoTag" src="" autoplay class="object-contain w-full rounded-t-3xl"></video>
                    <div id="localUsername" class="absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                        Me
                    </div>
                </div>
            </div>

            <div class="flex flex-col space-y-2">
                <button id='toggleMicrophone' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
                </button>

                <button id='toggleCamera' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
                </button>

                <button id='toggleScreen' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
                </button>

                <button id='leaveMeeting' class="bg-red-400 text-white w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
                </button>
                
            </div>
        </div>

In the Implementing the Join Meeting section we are removing the hidden class from the #meetingView div.

In the above code snippet we have created the #meetingView div, in this container we will build the UI for the meeting.

Lets go through each part of the #meetingView container and explain its function:

  • #activeSpeakerContainer - Here we will show the active speaker if there are multiple people in the meeting, and if it is just a 2 person meeting then we will show the remote participant here

    • #activeSpeakerVideo - This is the video tag that will show the video of the active speaker
    • #activeSpeakerUsername - This will contain the username of the active speaker
  • #remoteParticipantContainer - This will contain the video tiles of the remote participants

    • #localParticiapntContainer - We will create default first tile to show the user his/her own video
    • #localVideoTag - Video tag to show the local video that is beign shared by the current user
    • #localUsername - Username of the current user
  • #toggleMicrophone - Button to mute/unmute microphone

  • #toggleCamera - Button to mute/unmute camera

  • #toggleScreen - Button to share screen

  • #leaveMeeting - Button to exit the meeting

  • #leaveMeetingView - When the user leaves the meeting we will display this view and hide the #meetingView

Implementing Meeting View Logic

Now lets create the logic to wire up the Meeting View User Interface.

Handle onlineParticipants event

The onlineParticipants event is triggered multiple times during the meeting lifecycle.

It is contains the array of participants currently present in the meeting.

We will use the onlineParticipants event to show the list of online participants in the meeting.

  meeting.on("onlineParticipants", function(participants) {
    
    for (let participantInfo of participants) {
        // Checking if a div to hold the participant already exists or not.
        // If it exisits then skipping.
        // Also checking if the participant is not the current participant.
      if (!jquery(`#participant-${participantInfo._id}`)[0] && participantInfo._id !== meeting.participantInfo._id) {
        jquery("#remoteParticipantContainer").append(
          `
          <div id="participant-${participantInfo._id}" class="w-48 h-48 rounded-3xl bg-gray-900 relative">
            <video id="video-${participantInfo._id}" src="" autoplay class="object-contain w-full rounded-t-3xl"></video>
            <video id="audio-${participantInfo._id}" src="" autoplay class="hidden"></video>
            <div class="absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                ${participantInfo.name}
            </div>
          </div>
          `
        );
      }
    }
  });

We are populating the remoteParticipants container with the list of participants and also creating video and audio tag for each participants.

Currently the video and audio tag do not contain any remote stream, but we will add the remote stream to these tags in remoteTrackStarted event handler.

For now we are just looping through all the participants, checking if the tag for the participant already exists in the remoteParticipants container or not.

If the participant does not exists then we are creating a container to hold the participant's video tag to display the video, audio tag for the participants audio and a field to hold the username.

We specify the id of the container as participant-<participant_id>.

Handling participantLeft event

The participantLeft event is triggered when the participant leaves the meeting.

  meeting.on("participantLeft", function(participantInfo) {
    jquery("#participant-" + participantInfo._id).remove();
    if (participantInfo._id === activeSpeakerId) {
      jquery("#activeSpeakerUsername").text("");
      jquery("#activeSpeakerUsername").addClass("hidden");
    }
  });

We will remove the participant div.

Handling remoteTrackStarted event

The remoteTrackStarted event is triggered when the participants in the meeting share their camera or microphone. It is also triggered when user joins an existing meeting where participants are already sharing their camera or microphone.

  meeting.on("remoteTrackStarted", function(remoteTrackItem) {
    jquery("#activeSpeakerUsername").removeClass("hidden");

    if (remoteTrackItem.type === "video") {
      let mediaStream = new MediaStream();
      mediaStream.addTrack(remoteTrackItem.track);
      if (jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].srcObject = mediaStream;
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].play();
      }
    }

    if (remoteTrackItem.type === "audio") {
      let mediaStream = new MediaStream();
      mediaStream.addTrack(remoteTrackItem.track);
      if ( jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].srcObject = mediaStream;
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].play();
      }
    }
    setActiveSpeaker(remoteTrackItem);
  });

In the event handler we will find the div of the remoteParticipant and setting the srcObject property of the video or audio tag that we had already created in the onlineParticipants event handler depending upon the type of remote track.

In the end of the event handler we are calling the setActiveSpeaker method. This method will set the remote participant as activeSpeaker.

  function setActiveSpeaker(activeSpeaker) {

    if (activeSpeakerId  != activeSpeaker.participantSessionId) {
      jquery(`#participant-${activeSpeakerId}`).show();
    } 

    activeSpeakerId = activeSpeaker.participantSessionId;
    jquery(`#participant-${activeSpeakerId}`).hide();

    jquery("#activeSpeakerUsername").text(activeSpeaker.name || activeSpeaker.participant.name);
    
    if (jquery(`#video-${activeSpeaker.participantSessionId}`)[0]) {
      let stream = jquery(
        `#video-${activeSpeaker.participantSessionId}`
      )[0].srcObject;
      jquery("#activeSpeakerVideo")[0].srcObject = stream.clone();
    }
  
    if (activeSpeaker.participantSessionId === meeting.participantSessionId) {
      let stream = jquery(`#localVideoTag`)[0].srcObject;
      if (stream) {
        jquery("#localVideoTag")[0].srcObject = stream.clone();
      }
    }
  }

In this method we will hide the user from the remoteParticipantsContainer and move it to the center activeSpeaker block.

Handling remoteTrackStopped event

When the remote participant stop sharing their camera, screen or microphone the remoteTrackStopped event is triggered.

  meeting.on("remoteTrackStopped", function(remoteTrackItem) {
    if (remoteTrackItem.type === "video") {
      if ( jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].srcObject = null;
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].pause();
      }
      
      if (remoteTrackItem.participantSessionId === activeSpeakerId) {
        jquery("#activeSpeakerVideo")[0].srcObject = null;
        jquery("#activeSpeakerVideo")[0].pause();
      }
    }

    if (remoteTrackItem.type === "audio") {
      if (jquery("#audio-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].srcObject = null;
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].pause();
      }
    }
  });

In this event handler we are setting the srcObject property to null of the remote participant.

Handing activeSpeaker event

The activeSpeaker event is triggered to indicate which participant is currently speaking.

We will call our setActiveSpeaker method that we have previously created in this event handler.

  meeting.on("activeSpeaker", function(activeSpeaker) {
    setActiveSpeaker(activeSpeaker);
  });
Handling Microphone toggle

We have created a button with id #toggleMicrophone when this button is pressed we will mute/unmute the microphone.

To mute the microphone we will call the stopAudio() method of the Metered Video SDK.

And to un-mute or share the microphone we will call the startAudio() method of the Metered Video SDK.

We will use a global variable called as micOn to store the state of the microphone, whether it is currently begin shared or not.

  jquery("#toggleMicrophone").on("click",  async function() {
    if (micOn) {
      jquery("#toggleMicrophone").removeClass("bg-gray-500");
      jquery("#toggleMicrophone").addClass("bg-gray-400");
      micOn = false;
      await meeting.stopAudio();
    } else {
      jquery("#toggleMicrophone").removeClass("bg-gray-400");
      jquery("#toggleMicrophone").addClass("bg-gray-500");
      micOn = true;
      await meeting.startAudio();
    }
  });

Handling camera toggle

We have created a button with id #toggleCamera when this button is pressed we will share/un-share the camera.

To share the camera we will call the startVideo() method of Metered Video SDK.

To stop sharing the camera we will call the stopVideo() method.

In this method we will also show the preview of the video that the user is currently sharing.

To get the local video stream we will call the getLocalVideoStream() method to fetch the video stream currently shared by the user.

We will then display the video stream in the #localVideoTag

  jquery("#toggleCamera").on("click",  async function() {
    if (cameraOn) {
      jquery("#toggleCamera").removeClass("bg-gray-500");
      jquery("#toggleCamera").addClass("bg-gray-400");
      jquery("#toggleScreen").removeClass("bg-gray-500");
      jquery("#toggleScreen").addClass("bg-gray-400");
      cameraOn = false;
      await meeting.stopVideo();
      const tracks = localVideoStream.getTracks();
      tracks.forEach(function (track) {
        track.stop();
      });
      localVideoStream = null;
      jquery("#localVideoTag")[0].srcObject = null;
    } else {
      jquery("#toggleCamera").removeClass("bg-gray-400");
      jquery("#toggleCamera").addClass("bg-gray-500");
      cameraOn = true;
      await meeting.startVideo();
      localVideoStream = await meeting.getLocalVideoStream();
      jquery("#localVideoTag")[0].srcObject = localVideoStream;
    }
  });

When the user decides to stop sharing the video, we will cleanup the localVideoStream variable by stopping the video tracks and also call the  stopVideo() method of the Metered Video SDK.

Handling Screen Sharing

We have created a with id #toggleScreen when this button is pressed we will share the screen.

To share the screen we will call the startScreenShare() method of the Metered Video SDK.

This method returns the video stream of the screen that is currently begin shared, and we will set the video stream to localVideoTag to show the user preview of their screen which is currently being shared.

  jquery("#toggleScreen").on("click",  async function() {
    if (screenSharingOn) {
      jquery("#toggleScreen").removeClass("bg-gray-500");
      jquery("#toggleScreen").addClass("bg-gray-400");
      screenSharingOn = false;
      await meeting.stopVideo();
      const tracks = localVideoStream.getTracks();
      tracks.forEach(function (track) {
        track.stop();
      });
      localVideoStream = null;
      jquery("#localVideoTag")[0].srcObject = null;

    } else {
      jquery("#toggleScreen").removeClass("bg-gray-400");
      jquery("#toggleScreen").addClass("bg-gray-500");
      jquery("#toggleCamera").removeClass("bg-gray-500");
      jquery("#toggleCamera").addClass("bg-gray-400");
      screenSharingOn = true;
      localVideoStream = await meeting.startScreenShare();
      jquery("#localVideoTag")[0].srcObject = localVideoStream;
    }
  });

To stop the screen sharing we will call the meeting.stopVideo() method and it will stop the screensharing.

Handling Leave Meeting

To implement leave meeting we will call the leaveMeeting() method of the Metered Video SDK.

And we will hide the meeting view and show the leaveMeeting View.

  jquery("#leaveMeeting").on("click", async function() {
    await meeting.leaveMeeting();
    jquery("#meetingView").addClass("hidden");
    jquery("#leaveMeetingView").removeClass("hidden");
  });
        <div id="leaveMeetingView" class="hidden">
            <h1 class="text-center text-3xl mt-10 font-bold">
                You have left the meeting 
            </h1>
        </div>

Putting it all together

Here is final code of our meeting.blade.php which contains the complete Meeting UI

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">


        <style>
            body {
                font-family: 'Nunito', sans-serif;
            }
        </style>

        <script src="https://cdn.metered.ca/sdk/video/1.4.5/sdk.min.js"></script>

        <script>
            window.METERED_DOMAIN = "{{ $METERED_DOMAIN }}";
            window.MEETING_ID = "{{ $MEETING_ID }}";

        </script>

        @vite(['resources/css/app.css', 'resources/js/app.js'])


    </head>
    <body class="antialiased">
        
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">

            <div id="waitingArea" class="max-h-screen">
                <div class="py-4">
                    <h1 class="text-2xl">Meeting Lobby</h1>
                </div>
    
    
                <div class="max-w-2xl  flex flex-col space-y-4 ">
                    
                    <div class="flex items-center justify-center w-full rounded-3xl bg-gray-900">
                        <video id='waitingAreaLocalVideo' class="h-96" autoplay muted></video>
                    </div>
    
                    <div class="flex space-x-4 mb-4 justify-center">
    
                        <button id='waitingAreaToggleMicrophone' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                            <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z" /></svg>
                        </button>
    
                        <button id='waitingAreaToggleCamera' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                            <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>           
                        </button>
    
                    </div>
                    <div class="flex flex-col space-y-4 space-x-2 text-sm">
                        <div class="flex space-x-2 items-center">
                            <label>
                                Name:
                                <input class="text-xs" id="username" type="text"  placeholder="Name"/>
                            </label>
    
                            <label>
                                Camera:
                                <select class="text-xs" id='cameraSelectBox'>
                                </select>
                            </label>
        
                            <label>
                                Microphone:
                                <select class="text-xs" id='microphoneSelectBox'>
                                </select>
                            </label>
                        </div>

                        <div>
                            <button id='joinMeetingBtn' class="inline-flex items-center px-4 py-2 border border-transparent text-sm rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
                                Join Meeting
                            </button>
                        </div>
                    </div>
    
                </div>
      
            </div>
        </div> 

        <div id='meetingView' class="hidden flex w-screen h-screen space-x-4 p-10">

            <div id="activeSpeakerContainer" class=" bg-gray-900 rounded-3xl flex-1 flex relative">
                <video id="activeSpeakerVideo" src="" autoplay class=" object-contain w-full rounded-t-3xl"></video>
                <div id="activeSpeakerUsername" class="hidden absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                    
                </div>
            </div>  

            <div id="remoteParticipantContainer" class="flex flex-col space-y-4">
                <div id="localParticiapntContainer" class="w-48 h-48 rounded-3xl bg-gray-900 relative">
                    <video id="localVideoTag" src="" autoplay class="object-contain w-full rounded-t-3xl"></video>
                    <div id="localUsername" class="absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                        Me
                    </div>
                </div>
            </div>

            <div class="flex flex-col space-y-2">
                <button id='toggleMicrophone' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path></svg>
                </button>

                <button id='toggleCamera' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
                </button>

                <button id='toggleScreen' class="bg-gray-400 w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path></svg>
                </button>

                <button id='leaveMeeting' class="bg-red-400 text-white w-10 h-10 rounded-md p-2">
                    <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path></svg>
                </button>
                
            </div>
        </div>

        <div id="leaveMeetingView" class="hidden">
            <h1 class="text-center text-3xl mt-10 font-bold">
                You have left the meeting 
            </h1>
        </div>
    </body>
</html>
meeting.blade.php

The final code for our app.js file that contains the logic for the meeting UI

import './bootstrap';
import jquery from 'jquery';

let meetingJoined = false;
const meeting = new Metered.Meeting();
let cameraOn = false;
let micOn = false;
let screenSharingOn = false;
let localVideoStream = null;
let activeSpeakerId = null;
let meetingInfo = {};

async function initializeView() {
    /**
     * Populating the cameras
     */
     const videoInputDevices = await meeting.listVideoInputDevices();
     const videoOptions = [];
     for (let item of videoInputDevices) {
        videoOptions.push(
            `<option value="${item.deviceId}">${item.label}</option>`
        )
     }
    jquery("#cameraSelectBox").html(videoOptions.join(""));

    /**
     * Populating Microphones
     */
    const audioInputDevices = await meeting.listAudioInputDevices();
    const audioOptions = [];
    for (let item of audioInputDevices) {
        audioOptions.push(
            `<option value="${item.deviceId}">${item.label}</option>`
        )
    }
    jquery("#microphoneSelectBox").html(audioOptions.join(""));
    

    /**
     * Mute/Unmute Camera and Microphone
     */
    jquery("#waitingAreaToggleMicrophone").on("click", function() {
        if (micOn) {
            micOn = false;
            jquery("#waitingAreaToggleMicrophone").removeClass("bg-gray-500");
            jquery("#waitingAreaToggleMicrophone").addClass("bg-gray-400");
        } else {
            micOn = true;
            jquery("#waitingAreaToggleMicrophone").removeClass("bg-gray-400");
            jquery("#waitingAreaToggleMicrophone").addClass("bg-gray-500");
        }
    });

    jquery("#waitingAreaToggleCamera").on("click", async function() {
        if (cameraOn) {
            cameraOn = false;
            jquery("#waitingAreaToggleCamera").removeClass("bg-gray-500");
            jquery("#waitingAreaToggleCamera").addClass("bg-gray-400");
            const tracks = localVideoStream.getTracks();
            tracks.forEach(function (track) {
              track.stop();
            });
            localVideoStream = null;
            jquery("#waitingAreaLocalVideo")[0].srcObject = null;
        } else {
            cameraOn = true;
            jquery("#waitingAreaToggleCamera").removeClass("bg-gray-400");
            jquery("#waitingAreaToggleCamera").addClass("bg-gray-500");
            localVideoStream = await meeting.getLocalVideoStream();
            jquery("#waitingAreaLocalVideo")[0].srcObject = localVideoStream;
            cameraOn = true;
        }
    });

    /**
     * Adding Event Handlers
     */
         jquery("#cameraSelectBox").on("change", async function() {
          const deviceId = jquery("#cameraSelectBox").val();
          await meeting.chooseVideoInputDevice(deviceId);
          if (cameraOn) {
              localVideoStream = await meeting.getLocalVideoStream();
              jquery("#waitingAreaLocalVideo")[0].srcObject = localVideoStream;
          }
      });
  
      jquery("#microphoneSelectBox").on("change", async function() {
          const deviceId = jquery("#microphoneSelectBox").val();
          await meeting.chooseAudioInputDevice(deviceId);
      });
  
}
initializeView();

jquery("#joinMeetingBtn").on("click", async function () {
    var username = jquery("#username").val();
    if (!username) {
      return alert("Please enter a username");
    }
  
    try {
      meetingInfo = await meeting.join({
        roomURL: `${window.METERED_DOMAIN}/${window.MEETING_ID}`,
        name: username,
      });
      
      console.log("Meeting joined", meetingInfo);
      jquery("#waitingArea").addClass("hidden");
      jquery("#meetingView").removeClass("hidden");
      jquery("#meetingAreaUsername").text(username);

      /**
       * If camera button is clicked on the meeting view
       * then sharing the camera after joining the meeting.
       */
      if (cameraOn) {
        await meeting.startVideo();
        jquery("#localVideoTag")[0].srcObject = localVideoStream;
        jquery("#localVideoTag")[0].play();
        jquery("#toggleCamera").removeClass("bg-gray-400");
        jquery("#toggleCamera").addClass("bg-gray-500");
      }
      
      /**
       * Microphone button is clicked on the meeting view then
       * sharing the microphone after joining the meeting
       */
      if (micOn) {
        jquery("#toggleMicrophone").removeClass("bg-gray-400");
        jquery("#toggleMicrophone").addClass("bg-gray-500");
        await meeting.startAudio();
      }

    } catch (ex) {
      console.log("Error occurred when joining the meeting", ex);
    }
  });

  /**
   * Handling Events
   */
  meeting.on("onlineParticipants", function(participants) {
    
    for (let participantInfo of participants) {
      if (!jquery(`#participant-${participantInfo._id}`)[0] && participantInfo._id !== meeting.participantInfo._id) {
        jquery("#remoteParticipantContainer").append(
          `
          <div id="participant-${participantInfo._id}" class="w-48 h-48 rounded-3xl bg-gray-900 relative">
            <video id="video-${participantInfo._id}" src="" autoplay class="object-contain w-full rounded-t-3xl"></video>
            <video id="audio-${participantInfo._id}" src="" autoplay class="hidden"></video>
            <div class="absolute h-8 w-full bg-gray-700 rounded-b-3xl bottom-0 text-white text-center font-bold pt-1">
                ${participantInfo.name}
            </div>
          </div>
          `
        );
      }
    }
  });

  meeting.on("participantLeft", function(participantInfo) {
    jquery("#participant-" + participantInfo._id).remove();
    if (participantInfo._id === activeSpeakerId) {
      jquery("#activeSpeakerUsername").text("");
      jquery("#activeSpeakerUsername").addClass("hidden");
    }
  });

  meeting.on("remoteTrackStarted", function(remoteTrackItem) {
    jquery("#activeSpeakerUsername").removeClass("hidden");

    if (remoteTrackItem.type === "video") {
      let mediaStream = new MediaStream();
      mediaStream.addTrack(remoteTrackItem.track);
      if (jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].srcObject = mediaStream;
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].play();
      }
    }

    if (remoteTrackItem.type === "audio") {
      let mediaStream = new MediaStream();
      mediaStream.addTrack(remoteTrackItem.track);
      if ( jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].srcObject = mediaStream;
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].play();
      }
    }
    setActiveSpeaker(remoteTrackItem);
  });

  meeting.on("remoteTrackStopped", function(remoteTrackItem) {
    if (remoteTrackItem.type === "video") {
      if ( jquery("#video-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].srcObject = null;
        jquery("#video-" + remoteTrackItem.participantSessionId)[0].pause();
      }
      
      if (remoteTrackItem.participantSessionId === activeSpeakerId) {
        jquery("#activeSpeakerVideo")[0].srcObject = null;
        jquery("#activeSpeakerVideo")[0].pause();
      }
    }

    if (remoteTrackItem.type === "audio") {
      if (jquery("#audio-" + remoteTrackItem.participantSessionId)[0]) {
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].srcObject = null;
        jquery("#audio-" + remoteTrackItem.participantSessionId)[0].pause();
      }
    }
  });


  meeting.on("activeSpeaker", function(activeSpeaker) {
    setActiveSpeaker(activeSpeaker);
  });

  function setActiveSpeaker(activeSpeaker) {

    if (activeSpeakerId  != activeSpeaker.participantSessionId) {
      jquery(`#participant-${activeSpeakerId}`).show();
    } 

    activeSpeakerId = activeSpeaker.participantSessionId;
    jquery(`#participant-${activeSpeakerId}`).hide();

    jquery("#activeSpeakerUsername").text(activeSpeaker.name || activeSpeaker.participant.name);
    
    if (jquery(`#video-${activeSpeaker.participantSessionId}`)[0]) {
      let stream = jquery(
        `#video-${activeSpeaker.participantSessionId}`
      )[0].srcObject;
      jquery("#activeSpeakerVideo")[0].srcObject = stream.clone();
    }
  
    if (activeSpeaker.participantSessionId === meeting.participantSessionId) {
      let stream = jquery(`#localVideoTag`)[0].srcObject;
      if (stream) {
        jquery("#localVideoTag")[0].srcObject = stream.clone();
      }
    }
  }

  jquery("#toggleMicrophone").on("click",  async function() {
    if (micOn) {
      jquery("#toggleMicrophone").removeClass("bg-gray-500");
      jquery("#toggleMicrophone").addClass("bg-gray-400");
      micOn = false;
      await meeting.stopAudio();
    } else {
      jquery("#toggleMicrophone").removeClass("bg-gray-400");
      jquery("#toggleMicrophone").addClass("bg-gray-500");
      micOn = true;
      await meeting.startAudio();
    }
  });

  
  jquery("#toggleCamera").on("click",  async function() {
    if (cameraOn) {
      jquery("#toggleCamera").removeClass("bg-gray-500");
      jquery("#toggleCamera").addClass("bg-gray-400");
      jquery("#toggleScreen").removeClass("bg-gray-500");
      jquery("#toggleScreen").addClass("bg-gray-400");
      cameraOn = false;
      await meeting.stopVideo();
      const tracks = localVideoStream.getTracks();
      tracks.forEach(function (track) {
        track.stop();
      });
      localVideoStream = null;
      jquery("#localVideoTag")[0].srcObject = null;
    } else {
      jquery("#toggleCamera").removeClass("bg-gray-400");
      jquery("#toggleCamera").addClass("bg-gray-500");
      cameraOn = true;
      await meeting.startVideo();
      localVideoStream = await meeting.getLocalVideoStream();
      jquery("#localVideoTag")[0].srcObject = localVideoStream;
    }
  });

  
  jquery("#toggleScreen").on("click",  async function() {
    if (screenSharingOn) {
      jquery("#toggleScreen").removeClass("bg-gray-500");
      jquery("#toggleScreen").addClass("bg-gray-400");
      screenSharingOn = false;
      await meeting.stopVideo();
      const tracks = localVideoStream.getTracks();
      tracks.forEach(function (track) {
        track.stop();
      });
      localVideoStream = null;
      jquery("#localVideoTag")[0].srcObject = null;

    } else {
      jquery("#toggleScreen").removeClass("bg-gray-400");
      jquery("#toggleScreen").addClass("bg-gray-500");
      jquery("#toggleCamera").removeClass("bg-gray-500");
      jquery("#toggleCamera").addClass("bg-gray-400");
      screenSharingOn = true;
      localVideoStream = await meeting.startScreenShare();
      jquery("#localVideoTag")[0].srcObject = localVideoStream;
    }
  });

  
  jquery("#leaveMeeting").on("click", async function() {
    await meeting.leaveMeeting();
    jquery("#meetingView").addClass("hidden");
    jquery("#leaveMeetingView").removeClass("hidden");
  });
app.js

Our welcome.blade.php file that contains the controls to join an existing meeting or create a new meeting

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>

        <!-- Fonts -->
        <link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700&display=swap" rel="stylesheet">


        <style>
            body {
                font-family: 'Nunito', sans-serif;
            }
        </style>

        @vite(['resources/css/app.css', 'resources/js/app.js'])

    </head>
    <body class="antialiased">
        
        <div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
            <div class="py-4">
                <h1 class="text-2xl">Video Chat Application</h1>
            </div>


            <div class="max-w-2xl">
                <div class="grid md:grid-cols-8 grid-cols-1 mt-4">
                    <div class="md:col-span-5">
                        <form method="post" action="{{ route('validateMeeting') }}">
                          {{ csrf_field() }}
                        <div class="mt-1 flex rounded-md shadow-sm">
                          <div class="relative flex items-stretch flex-grow focus-within:z-10">
                            <div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
                              <!-- Heroicon name: solid/users -->
                              <svg class="h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                                <path d="M9 6a3 3 0 11-6 0 3 3 0 016 0zM17 6a3 3 0 11-6 0 3 3 0 016 0zM12.93 17c.046-.327.07-.66.07-1a6.97 6.97 0 00-1.5-4.33A5 5 0 0119 16v1h-6.07zM6 11a5 5 0 015 5v1H1v-1a5 5 0 015-5z" />
                              </svg>
                            </div>
                            <input type="text" name="meetingId" id="meetingId" class="focus:ring-indigo-500 focus:border-indigo-500 block w-full rounded-none rounded-l-md pl-10 sm:text-sm border-gray-300" placeholder="Meeting ID">
                          </div>
                          <button type="submit" class="-ml-px relative inline-flex items-center space-x-2 px-4 py-2 border border-gray-300 text-sm font-medium rounded-r-md text-gray-700 bg-gray-50 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
                            <span>Join Meeting</span>
                          </button>
                        </div>
                        </form>

                      </div>
                      <div class="my-2 sm:my-0 flex items-center justify-center">
                        <span class="text-xs uppercase font-bold text-gray-400 px-1">OR</span>
                      </div>

                      <div class="md:col-span-2">
                        <form method="post" action="{{ route('createMeeting') }}">
                            {{ csrf_field() }}
                            <button type="submit" class="mt-1 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">Create New Meeting</button>
                        </form>
                    </div>
                </div>

            </div>
  
        </div> 
    </body>
</html>
welcome.blade.php

And our web.php file and MeetingContoller.php

<?php

use Illuminate\Support\Facades\Route;

use App\Http\Controllers\MeetingController;
/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
// URL::forceScheme('https');

Route::get('/', function () {
    return view('welcome');
});

Route::post("/createMeeting", [MeetingController::class, 'createMeeting'])->name("createMeeting");

Route::post("/validateMeeting", [MeetingController::class, 'validateMeeting'])->name("validateMeeting");

Route::get("/meeting/{meetingId}", function($meetingId) {

    $METERED_DOMAIN = env('METERED_DOMAIN');
    return view('meeting', [
        'METERED_DOMAIN' => $METERED_DOMAIN,
        'MEETING_ID' => $meetingId
    ]);
});
web.php

MeetingController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;




class MeetingController extends Controller
{

    public function createMeeting(Request $request) {
        
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');
    

        // Contain the logic to create a new meeting
        $response = Http::post("https://{$METERED_DOMAIN}/api/v1/room?secretKey={$METERED_SECRET_KEY}", [
            'autoJoin' => true
        ]);

        $roomName = $response->json("roomName");
        
        return redirect("/meeting/{$roomName}"); // We will update this soon.
    }

    public function validateMeeting(Request $request) {
        $METERED_DOMAIN = env('METERED_DOMAIN');
        $METERED_SECRET_KEY = env('METERED_SECRET_KEY');

        $meetingId = $request->input('meetingId');

        // Contains logic to validate existing meeting
        $response = Http::get("https://{$METERED_DOMAIN}/api/v1/room/{$meetingId}?secretKey={$METERED_SECRET_KEY}");

        $roomName = $response->json("roomName");


        if ($response->status() === 200)  {
            return redirect("/meeting/{$roomName}"); // We will update this soon
        } else {
            return redirect("/?error=Invalid Meeting ID");
        }
    }
}
MeetingController.php

Github

You can find the Github repo for this project here: Group Video Chat App with PHP Laravel and JavaScript

Conclusion

So we have built the complete video calling application in PHP Laravel and WebRTC using the Metered Video SDK.