Building a Video Chat Web App with ASP.NET Core Blazor and Twilio Programmable Video

August 25, 2020
Written by
David Pine
Contributor
Opinions expressed by Twilio contributors are their own
Reviewed by
AJ Saulsberry
Contributor
Opinions expressed by Twilio contributors are their own

asp-net-core-blazor-video-chat-app.png

Realtime user interaction is a great way to enhance the communication and collaboration capabilities of a web application. Video chatting with colleagues, friends, or family has become the new norm, and video chat is an obvious choice for sales, customer support, and education sites. For remote workforces, video chat improves the effectiveness of team collaboration.

But is video chat practical to implement?

If you’re developing with Blazor WebAssembly (WASM) on the front end and ASP.NET Core for your server, the answer is: Yes. Twilio Programmable Video and the Twilio helper libraries for JavaScript and .NET enable you to efficiently add robust video chat to your application.

Whether you’re building solutions for telemedicine, distance learning, or workforce engagement, Twilio has GDPR compliance and HIPAA eligibility. With Twilio Programmable Video you can build secure video applications that scale.

In this post you’ll learn how to create a fully operational video chat application using the Twilio JavaScript SDK in your Blazor single page application (SPA) and the Twilio SDK for C# and .NET in your ASP.NET Core server code. You’ll build the interactions required to create and join video chat rooms, and to publish and subscribe to participant audio and video tracks.

Prerequisites

You’ll need the following technologies and tools to build the project described in this post:

Software and services

Hardware

To test and fully experience the completed app you’ll need the following hardware:

  • A connected video device, such as a laptop’s integrated webcam
  • A second video device, like the highly regarded Microsoft LifeCam Studio

Knowledge and experience

To get the most out of this post you should have:

There is a companion repository for this post available on GitHub. It contains the complete source code for this tutorial.

Getting started with Twilio Programmable Video

You’ll need a free Twilio trial account and a Twilio Programmable Video project to be able to build this project with the Twilio Video SDK. Getting set up will take just a few minutes.

Once you have a Twilio account, go to the Twilio Console and perform the following steps:

  1. On the Dashboard home, locate your Account SID and Auth Token and copy them to a safe place.
  2. Select the Programmable Video section of the Console.
  3. Under Tools > API Keys, create a new API key with a friendly name of your choice and copy the Account SID and API Secret to a safe place.

The credentials you just acquired are user secrets, so it’s a good idea not to store them in the project source code. One way to keep them safe and make them accessible in your project configuration is to store them as environment variables on your development machine.

ASP.NET Core can access environment variables through the Microsoft.Extensions.Configuration package so they can be used as properties of an IConfiguration object in the Startup class. The following instructions show you how to do this on Windows.

Execute the following commands at a Windows or PowerShell command prompt, substituting your credentials for the placeholders. For other operating systems, use comparable commands to create the same environment variables.

setx TWILIO_ACCOUNT_SID [Account SID]
setx TWILIO_API_SECRET [API Secret]
setx TWILIO_API_KEY [SID]

If you prefer, or if your development environment requires it, you can place these values in the appsettings.development.json file as follows, but be careful not to expose this file in a source code repository or other easily accessible location.

{
  "Logging": {
    "LogLevel": {
      "Default": "Warning"
    }
  },
  "AllowedHosts": "*",
  "TwilioAccountSid":"AccountSID",
  "TwilioApiSecret":"API Secret",
  "TwilioApiKey":"SID"
}

You may want to add the appsettings.development.json file to your .gitignore for this solution to protect your credentials.

Creating the video chat solution

You can use the .NET tooling to create Blazor WebAssembly (WASM) web applications from either the Visual Studio 2019 user interface or the .NET Core CLI. When you run either one, the tooling will create a Visual Studio Solution (.sln) file and three C# project (.csproj) files:

One ASP.NET Core 3.1 project:

Blazor.Twilio.Video.Server – responsible for serving the Blazor WASM client app to client browsers and providing a Web API.

Two .NET Standard 2.1 projects:

Blazor.Twilio.Video.Client – responsible for the user interface, including how video chat rooms are created and joined, and for hosting the participant video stream.

Blazor.Twilio.Video.Shared – used by the .Client and .Server projects to bridge the gap between the server’s Web API and the client HTTP calls with common object graphs.

See Target frameworks in SDK-style projects for more information about how target frameworks work and the valid combinations of frameworks.

Visual Studio 2019: From the menu bar, select File > New Project. Select Blazor App from the list of templates.

The Configure your new project window should open and display “Blazor App” as the project type. For Project name, enter “Blazor.Twilio.Video”. Be sure to include the dots. Pick any appropriate local directory for Location.

Check Place solution and project in the same directory. Because the tooling is creating a multi-tier folder structure and multiple projects, it will actually place the solution (.sln) file in a parent folder and the project folders underneath, in standard fashion. Placing the solution file in a separate folder will create an additional, unneeded, level.

.NET Core CLI: execute the following command-line instruction in the directory where you’d like to create the top-level directory for the solution:

dotnet new blazorwasm --hosted -n Blazor.Twilio.Video

You should see a number of lines of output followed by a final “Restore succeeded” message.

Adding NuGet packages for Twilio and SignalR

The ASP.NET Core server application will use the Twilio SDK for C# and .NET to access Twilio Programmable Video.

You can install the Twilio, SignalR Client, and MessagePack NuGet packages with the Visual Studio 2019 NuGet Package Manager, Package Manager Console, or the .NET Core CLI.

Here’s the .NET Core CLI dotnet add command-line instruction:

dotnet add Server/Blazor.Twilio.Video.Server.csproj package Twilio

The Server/Blazor.Twilio.Video.Server.csproj file should include the package references in an <ItemGroup> node, as shown below, if the command completed successfully. (The version numbers in your project may be higher.)

<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="3.2.0" />
    <PackageReference Include="Twilio" Version="5.45.1" />
</ItemGroup>

The Blazor.Twilio.Video client application will use the SignalR MessagePack protocol package to provide fast and compact binary serialization. It will also use the SignalR Client package to provide access to SignalR hubs.

Here’s the dotnet add command-line instruction for the SignalR MessagePack client package:

dotnet add Client/Blazor.Twilio.Video.Client.csproj package Microsoft.AspNetCore.SignalR.Client

To add the SignalR MessagePack protocol package, use the following dotnet add command:

dotnet add Client/Blazor.Twilio.Video.Client.csproj package Microsoft.AspNetCore.SignalR.Protocols.MessagePack

The Blazor.Twilio.Video.Client.csproj file should contain an <ItemGroup> section that looks like the following:

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="3.2.1" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Build" Version="3.2.1" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="3.2.1" PrivateAssets="all" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="3.1.6" />
    <PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="3.1.6" />
    <PackageReference Include="System.Net.Http.Json" Version="3.2.0" />
  </ItemGroup>

The minor version numbers in your project may be higher.

Enabling nullable reference types

C# 8.0 introduced nullable reference types and they can make a useful contribution to this solution. You enable nullable reference types by modifying the C# project (.csproj) file.

In all of the .csproj project files for the .Client, .Server, and .Shared projects, add the following XML element to the top <ItemGroup> node:

<Nullable>enable</Nullable>

Deleting unneeded template files

You won’t need some of the files created by the tooling. Delete the following files, but not the folders:

/Client
    /Pages
        Counter.razor
        FetchData.razor
    /Shared
        NavMenu.razor
        SurveyPrompt.razor
    /wwwroot
        bootstrap/**
        open-iconic/**
/Server
   /Controllers
       WeatherForecastController.cs
/Shared
   WeatherForecast.cs

When you’re finished your Explorer folder and file structure should look like the following:

Visual Studio Code screenshot 1: after deleting

Creating the folder and file structure

Add the following folders and files to the existing folders and files:

/Client
    /Components
        Cameras.razor
        Cameras.razor.cs
    /Interop
        VideoJS.cs
    /Pages
        Index.razor.cs
    /wwwroot
        site.js
/Server
    /Controllers
        TwilioController.cs
    /Hubs
        NotificationHub.cs
    /Options
        TwilioSettings.cs
    /Services
        TwilioService.cs
/Shared
    CameraState.cs
    Device.cs
    HubEndpoint.cs
    RoomDetails.cs
    TwilioJwt.cs

The Client folder should look like the following:

Visual Studio Code screenshot 2: Client project after adding

The Server folder should look like the following:

Visual Studio Code screenshot 2: Server project after adding

The Shared folder should look like the following:

Visual Studio Code screenshot 2: Shared project after adding

Building and testing the configured solution

Build and run the application to ensure that it compiles and works properly: press F5 to do this from either Visual Studio or Visual Studio Code, or run the app from the .NET CLI with the dotnet run command:

dotnet run -p Server/Blazor.Twilio.Video.Server.csproj

You should see a default home page similar to the one created by the tooling, but missing the pieces provided by the files you removed.

Close the browser and end the terminal session.

Creating services

The server-side code needs to do several key things, one of them is to provide a JSON Web Token (JWT) to the client so the client can connect to the Twilio Programmable Video API. Doing so requires the Twilio Account SID, API Key, and API Secret you stored as environment variables. In ASP.NET Core, it’s common to use a strongly-typed C# class to represent the various settings.

Replace the contents of the Server/Options/TwilioSettings.cs file with the following C# code:

namespace Blazor.Twilio.Video.Server.Options
{
    public class TwilioSettings
    {
        /// <summary>
     /// The primary Twilio account SID, displayed prominently on your twilio.com/console dashboard.
     /// </summary>
        public string? AccountSid { get; set; }

        /// <summary>
        /// Signing Key SID, also known as the API SID or API Key.
        /// </summary>
        public string? ApiKey { get; set; }

        /// <summary>
        /// The API Secret that corresponds to the <see cref="ApiKey"/>.
        /// </summary>
        public string? ApiSecret { get; set; }
    }
}

These settings are configured in the Startup.ConfigureServices method, which maps the values from environment variables and the appsettings.json file to the IOptions<TwilioSettings> instances that are available for dependency injection. In this case, the environment variables are the only values needed for the TwilioSettings class.

Insert the following C# code in the Shared/RoomDetails.cs file:

namespace Blazor.Twilio.Video.Shared
{
    public class RoomDetails
    {
        public string? Id { get; set; } = null!;
        public string? Name { get; set; } = null!;
        public int ParticipantCount { get; set; }
        public int MaxParticipants { get; set; }
    }
}

The RoomDetails class is an object that represents a video chat room. You’ll also need a simple object to represent the Twilio JSON Web Token (JWT); add the following C# code to the Shared/TwilioJwt.cs file:

namespace Blazor.Twilio.Video.Shared
{
    public class TwilioJwt
    {
        public string? Token { get; set; } = null!;
    }
}

Note the use of the C# 8.0 ! (null-forgiving) operator in the property declaration.

To implement the VideoService, replace the contents of the Server/Services/TwilioService.cs file with the following code:

using Blazor.Twilio.Video.Server.Options;
using Blazor.Twilio.Video.Shared;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Twilio;
using Twilio.Base;
using Twilio.Jwt.AccessToken;
using Twilio.Rest.Video.V1;
using Twilio.Rest.Video.V1.Room;
using MicrosoftOptions = Microsoft.Extensions.Options;
using ParticipantStatus = Twilio.Rest.Video.V1.Room.ParticipantResource.StatusEnum;

namespace Blazor.Twilio.Video.Server.Services
{
    public class TwilioService
    {
        readonly TwilioSettings _twilioSettings;

        public TwilioService(MicrosoftOptions.IOptions<TwilioSettings> twilioOptions)
        {
            _twilioSettings =
                twilioOptions?.Value
             ?? throw new ArgumentNullException(nameof(twilioOptions));

            TwilioClient.Init(_twilioSettings.ApiKey, _twilioSettings.ApiSecret);
        }

        public TwilioJwt GetTwilioJwt(string? identity) =>
            new TwilioJwt
            {
                Token = new Token(
                    _twilioSettings.AccountSid,
                    _twilioSettings.ApiKey,
                    _twilioSettings.ApiSecret,
                    identity ?? Guid.NewGuid().ToString(),
                    grants: new HashSet<IGrant> { new VideoGrant() })
                .ToJwt()
            };

        public async ValueTask<IEnumerable<RoomDetails>> GetAllRoomsAsync()
        {
            var rooms = await RoomResource.ReadAsync();
            var tasks = rooms.Select(
                room => GetRoomDetailsAsync(
                    room,
                    ParticipantResource.ReadAsync(
                        room.Sid,
                        ParticipantStatus.Connected)));

            return await Task.WhenAll(tasks);

            static async Task<RoomDetails> GetRoomDetailsAsync(
                RoomResource room,
                Task<ResourceSet<ParticipantResource>> participantTask)
            {
                var participants = await participantTask;
                return new RoomDetails
                {
                    Name = room.UniqueName,
                    MaxParticipants = room.MaxParticipants ?? 0,
                    ParticipantCount = participants.Count()
                };
            }
        }
    }
}

The TwilioService class constructor takes an IOptions<TwilioSettings> instance and initializes the TwilioClient, given the supplied API Key and corresponding API Secret. This is done statically, and it enables future use of various resource-based functions. The implementation of the GetTwilioJwt is used to issue a new Twilio.Jwt.AccessToken.Token, given the Account SID, API Key, API Secret, identity, and a new instance of HashSet<IGrant> with a single VideoGrant object. Before returning, an invocation of the .ToJwt function converts the token instance into its string equivalent.

The GetAllRoomsAsync function returns a list of RoomDetails objects. It starts by awaiting the RoomResource.ReadAsync function, which will yield a ResourceSet<RoomResource> once the async operation returns. From this listing of rooms, the code projects a series of Task<RoomDetails> where it will ask for the corresponding ResourceSet<ParticipantResource> currently connected to the room specified with the room identifier, room.UniqueName.

You may notice some unfamiliar syntax in the GetAllRoomsService function if you’re not used to code after the return statement. C# 8 includes a static local function feature that enables functions to be written within the scope of the method body (“locally”), even after the return statement. They are static to ensure variables are not captured within the enclosing scope.

Note that for every room n that exists, GetRoomDetailsAsync is invoked to fetch the room’s connected participants. This can be a performance concern! Even though this is done asynchronously and in parallel, it should be considered a potential bottleneck and marked for refactoring. It isn't a concern in this demo project, as there are, at most, a few rooms.

Creating the API controller

The video controller will provide two HTTP GET endpoints for the Blazor client to use.

Endpoint

Verb

Type

Description

api/twilio/token

GET

JSON

an object with a token member assigned from the Twilio JWT

api/twilio/rooms

GET

JSON

array of room details: { name, participantCount, maxParticipants }

Replace the contents of the Server/Controllers/TwilioController.cs file with the following C# code:

using Blazor.Twilio.Video.Server.Services;
using Microsoft.AspNetCore.Mvc;
using System.Threading.Tasks;

namespace Blazor.Twilio.Video.Server.Controllers
{
    [
        ApiController,
        Route("api/twilio")
    ]
    public class TwilioController : ControllerBase
    {
        [HttpGet("token")]
        public IActionResult GetToken(
            [FromServices] TwilioService twilioService) =>
             new JsonResult(twilioService.GetTwilioJwt(User.Identity.Name));

        [HttpGet("rooms")]
        public async Task<IActionResult> GetRooms(
            [FromServices] TwilioService twilioService) =>
            new JsonResult(await twilioService.GetAllRoomsAsync());
    }
}

The controller is decorated with the ApiController attribute and a Route attribute containing the template "api/twilio".

In the TwilioController actions, the VideoService is injected using the FromServicesAttribute, which provides the instance to the methods.

Creating the notification hub

The ASP.NET Core application wouldn't be complete without the use of SignalR, which “... is an open-source library that simplifies adding real-time web functionality to apps. Real-time web functionality enables server-side code to push content to clients instantly.”

When a user creates a room in the application their client-side code will notify the server and, ultimately, other clients of the new room. This is done with a SignalR notification hub.

Add the following C# code to the Shared/HubEndpoint.cs file:

namespace Blazor.Twilio.Video.Shared
{
    public class HubEndpoints
    {
        public const string NotificationHub = "/notifications";
        public const string RoomsUpdated = nameof(RoomsUpdated);
    }
}

Replace the contents of the Server/Hubs/NotificationHub.cs file with the following C# code:

using Blazor.Twilio.Video.Shared;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace Blazor.Twilio.Video.Server.Hubs
{
    public class NotificationHub : Hub
    {
        public Task RoomsUpdated(string room) =>
            Clients.All.SendAsync(HubEndpoints.RoomsUpdated, room);
    }
}

The NotificationHub will asynchronously send a message to all other clients notifying them when a room is added.

Configuring the Server project Startup class

There are a few things that need to be updated in the Startup class and the ConfigureServices method.

Replace the C# using statements at to the top of the Server/Startup.cs file:

using Blazor.Twilio.Video.Server.Hubs;
using Blazor.Twilio.Video.Server.Options;
using Blazor.Twilio.Video.Server.Services;
using Blazor.Twilio.Video.Shared;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.ResponseCompression;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Net.Http.Headers;
using System.Linq;
using static System.Environment;

In the ConfigureServices method, replace all the existing code with the following code:

services.AddSignalR(options => options.EnableDetailedErrors = true)
        .AddMessagePackProtocol();
services.Configure<TwilioSettings>(settings =>
{
    settings.AccountSid = GetEnvironmentVariable("TWILIO_ACCOUNT_SID");
    settings.ApiSecret = GetEnvironmentVariable("TWILIO_API_SECRET");
    settings.ApiKey = GetEnvironmentVariable("TWILIO_API_KEY");
});
services.AddSingleton<TwilioService>();
services.AddControllersWithViews();
services.AddRazorPages();
services.AddResponseCompression(opts =>
    opts.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(
        new[] { "application/octet-stream" }));

This configures the application settings containing the Twilio API credentials, maps the video service to its corresponding implementation, assigns the root path for the SPA, and adds SignalR.

Replace the Configure method body with the following C# code:

app.UseResponseCompression();

if (env.IsDevelopment())
{
    app.UseDeveloperExceptionPage();
    app.UseWebAssemblyDebugging();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseBlazorFrameworkFiles();
app.UseStaticFiles(new StaticFileOptions
{
    HttpsCompression = HttpsCompressionMode.Compress,
    OnPrepareResponse = context =>
        context.Context.Response.Headers[HeaderNames.CacheControl] =
            $"public,max-age={86_400}"
});
app.UseRouting();
app.UseEndpoints(endpoints =>
{
    endpoints.MapRazorPages();
    endpoints.MapControllers();
    endpoints.MapHub<NotificationHub>(HubEndpoints.NotificationHub);
    endpoints.MapFallbackToFile("index.html");
});

This maps the notification endpoint to the implementation of the NotificationHub. Using this endpoint, the Angular SPA running in client browsers can send messages to all the other clients. SignalR provides the notification infrastructure for this process.

This concludes the server-side setup. From the command line or terminal window, run the dotnet build command to ensure that the application compiles. At this point, there should be no errors, but you should expect warning(s) at this point — you’ll address those soon.

Finalizing the Shared library classes

The Shared project is intended to hold common objects used by both the Server and the Client. Since you’re using Blazor WebAssembly on the client, you can use C# objects just as you do on the server.

Add the following C# code to the Shared/CameraState.cs file:

namespace Blazor.Twilio.Video.Shared
{
    public enum CameraState
    {
        LoadingCameras,
        FoundCameras,
        Error
    }
}

Add the following C# code to the Shared/Device.cs file:

namespace Blazor.Twilio.Video.Shared
{
    public class Device
    {
        public string DeviceId { get; set; } = null!;
        public string Label { get; set; } = null!;
    }
}

The other objects should have already been fulfilled in previous steps. At this point you shouldn’t need to alter anything in either the Server or Shared projects. You have a shared library and an ASP.NET Core server project that are both good to go.

Building the interop service

The VideoJS class serves as an interop between the Blazor WebAssembly C# code and the JavaScript running in the client browser. One of the biggest misconceptions about WebAssembly is that people assume JavaScript is no longer needed. That is not true. In fact, they complement each other.

Add the following C# code to the Client/Interop/VideoJS.cs file:

using Blazor.Twilio.Video.Shared;
using Microsoft.JSInterop;
using System.Threading.Tasks;

namespace Blazor.Twilio.Video.Client.Interop
{
    public static class VideoJS
    {
        public static ValueTask<Device[]> GetVideoDevicesAsync(
              this IJSRuntime? jsRuntime) =>
              jsRuntime?.InvokeAsync<Device[]>(
                  "videoInterop.getVideoDevices") ?? new ValueTask<Device[]>();

        public static ValueTask StartVideoAsync(
            this IJSRuntime? jSRuntime,
            string deviceId,
            string selector) =>
            jSRuntime?.InvokeVoidAsync(
                "videoInterop.startVideo",
                deviceId, selector) ?? new ValueTask();

        public static ValueTask<bool> CreateOrJoinRoomAsync(
            this IJSRuntime? jsRuntime,
            string roomName,
            string token) =>
            jsRuntime?.InvokeAsync<bool>(
                "videoInterop.createOrJoinRoom",
                roomName, token) ?? new ValueTask<bool>(false);

        public static ValueTask LeaveRoomAsync(
            this IJSRuntime? jsRuntime) =>
            jsRuntime?.InvokeVoidAsync(
                "videoInterop.leaveRoom") ?? new ValueTask();
    }
}

These interop functions expose JavaScript functionality to the Blazor WebAssembly code and return values from JavaScript to Blazor.

Building the client-side Blazor WebAssembly SPA

If you recall, earlier in this tutorial you deleted Bootstrap and open-iconic. Instead of having your server executable serve these static files to the client app, you can rely on third-party content delivery networks (CDNs) instead, such as https://cdnjs.com.

Replace the contents of the Client/wwwroot/index.html with the following markup:

<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
        <title>Blazor.Twilio.Video</title>
        <base href="/" />
        <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootswatch/4.5.0/darkly/bootstrap.min.css" />
        <link href="css/app.css" rel="stylesheet" />
        <script src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
        <script src="//media.twiliocdn.com/sdk/js/video/releases/2.3.0/twilio-video.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.5.1/jquery.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/twitter-bootstrap/4.5.0/js/bootstrap.bundle.min.js"></script>
    </head>
    <body>
        <app>
            <div class="d-flex justify-content-center align-items-center w-100 p-4">
                <h1 class="display-1 twilio-text">
                    <strong class="pr-4">Loading...</strong>
                    <span class="spinner-grow ml-auto" role="status" aria-hidden="true"></span>
                </h1>
            </div>
        </app>
        <div id="blazor-error-ui">
            An unhandled error has occurred.
            <a href="" class="reload">Reload</a>
            <a class="dismiss">🗙</a>
        </div>
        <script src="_framework/blazor.webassembly.js"></script>
        <script src="site.js"></script>
    </body>
</html>

You’ll need your Font Awesome script tag to completely configure the user interface. If you need to find the tag that was created when you signed up, go to https://fontawesome.com/kits and click the code number under Your Kits

In the index.html file, paste your Font Awesome <script> tag into the <head> element immediately after the <link href="css/app.css" rel="stylesheet" /> node.

The index.html file is all fairly standard HTML, but have a look at the <app> element in the <body>. This is where things get a bit interesting. Much like other single page application (SPA) frameworks, Blazor names its target host element as app by default, although this can be changed. When Blazor starts running it will hook into this DOM element and it will serve as the anchor for the client-side user experience. All markup inside the <app>…</app> element is replaced when the app is fully operational. You can place markup here to represent some sort of loading indicator, which is helpful to end-users to indicate progress.

Replace the contents of the Client/wwwroot/css/app.css file with the following CSS code:

:root {
    --twilio-red: #F22F46;
    --twilio-blue: #0D122B;
}

.twilio-text {
    color: var(--twilio-red);
}

a.list-group-item.list-group-item-action.active {
    background-color: var(--twilio-blue);
    border-color: var(--twilio-blue);
}

.twilio-btn-red {
    background-color: var(--twilio-red);
    border-color: var(--twilio-red);
    color: #fff;
}

    .twilio-btn-red:not(:disabled):hover {
        background-color: #D31027;
        border-color: #D31027;
    }

.twilio-btn-blue {
    background-color: var(--twilio-blue);
    border-color: var(--twilio-blue);
    color: #fff;
}

    .twilio-btn-blue:not(:disabled):hover {
        background-color: #00000C;
        border-color: #00000C;
    }

input:disabled, .btn:disabled {
    border-color: #444;
    cursor: not-allowed;
}

audio {
    display: none;
}

.participants-grid {
    display: grid;
    grid-gap: 5px;
    grid-template-rows: 1fr 1fr;
    grid-template-columns: 1fr 1fr;
}
    .participants-grid > div:nth-of-type(1) {
        grid-row: 1;
        grid-column: 1;
    }
    .participants-grid > div:nth-of-type(2) {
        grid-row: 1;
        grid-column: 2;
    }
    .participants-grid > div:nth-of-type(3) {
        grid-row: 2;
        grid-column: 1;
    }
    .participants-grid > div:nth-of-type(4) {
        grid-row: 2;
        grid-column: 2;
    }

app {
    position: relative;
    display: flex;
    flex-direction: column;
    height: 100vh;
}

.main {
    flex: 1;
}

.content {
    padding-top: 1.1rem;
}

.valid.modified:not([type=checkbox]) {
    outline: 1px solid #26b050;
}

.invalid {
    outline: 1px solid red;
}

.validation-message {
    color: red;
}

The cascading style sheet is now greatly simplified; it's nearly half the number of lines. It contains two :root variables that hold the official Twilio colors. The participant-grid class is how you style a two-by-two grid, in which one of the four tiles will represent a participant in the video chat.

Replace the contents of the Client/Shared/MainLayout.razor file with the following Razor markup:

@inherits LayoutComponentBase

<div class="container-fluid">
    @Body
</div>

In keeping with the separation of concerns principle, the markup acts as a template and the logic is separated into the Client/Pages/Index.razor.cs file. In Visual Studio, these two files are collapsed onto each other, as they’re known by convention to be related.

The markup consists of:

  • the camera selection component
  • an input for creating a new room
  • a listing of existing rooms
  • the participant grid

When the user enters a room name, the create room button is enabled. If the user clicks it, the TryAddRoom logic is called.

Add the following C# code to the Client/Pages/Index.razor.cs file:

using Blazor.Twilio.Video.Client.Interop;
using Blazor.Twilio.Video.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.JSInterop;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;

namespace Blazor.Twilio.Video.Client.Pages
{
    public partial class Index
    {
        [Inject] 
        protected IJSRuntime? JavaScript { get; set; }
        [Inject] 
        protected NavigationManager NavigationManager { get; set; } = null!;
        [Inject]
        protected HttpClient Http { get; set; } = null!;

        List<RoomDetails> _rooms = new List<RoomDetails>();

        string? _roomName;
        string? _activeCamera;
        string? _activeRoom;
        HubConnection? _hubConnection;

        protected override async Task OnInitializedAsync()
        {
            _rooms = await Http.GetFromJsonAsync<List<RoomDetails>>("api/twilio/rooms");

            _hubConnection = new HubConnectionBuilder()
                .AddMessagePackProtocol()
                .WithUrl(NavigationManager.ToAbsoluteUri(HubEndpoints.NotificationHub))
                .WithAutomaticReconnect()
                .Build();

            _hubConnection.On<string>(HubEndpoints.RoomsUpdated, OnRoomAdded);

            await _hubConnection.StartAsync();
        }

        async ValueTask OnLeaveRoom()
        {
            await JavaScript.LeaveRoomAsync();
            await _hubConnection.InvokeAsync(HubEndpoints.RoomsUpdated, _activeRoom = null);
            if (!string.IsNullOrWhiteSpace(_activeCamera))
            {
                await JavaScript.StartVideoAsync(_activeCamera, "#camera");
            }
        }

        async Task OnCameraChanged(string activeCamera) => 
            await InvokeAsync(() => _activeCamera = activeCamera);

        async Task OnRoomAdded(string roomName) =>
            await InvokeAsync(async () =>
            {
                _rooms = await Http.GetFromJsonAsync<List<RoomDetails>>("api/twilio/rooms");
                StateHasChanged();
            });

        protected async ValueTask TryAddRoom(object args)
        {
            if (_roomName is null || _roomName is { Length: 0 })
            {
                return;
            }

            var takeAction = args switch
            {
                KeyboardEventArgs keyboard when keyboard.Key == "Enter" => true,
                MouseEventArgs _ => true,
                _ => false
            };

            if (takeAction)
            {
                var addedOrJoined = await TryJoinRoom(_roomName);
                if (addedOrJoined)
                {
                    _roomName = null;
                }
            }
        }

        protected async ValueTask<bool> TryJoinRoom(string? roomName)
        {
            if (roomName is null || roomName is { Length: 0 })
            {
                return false;
            }

            var jwt = await Http.GetFromJsonAsync<TwilioJwt>("api/twilio/token");
            if (jwt?.Token is null)
            {
                return false;
            }

            var joined = await JavaScript.CreateOrJoinRoomAsync(roomName, jwt.Token);
            if (joined)
            {
                _activeRoom = roomName;
                await _hubConnection.InvokeAsync(HubEndpoints.RoomsUpdated, _activeRoom);
            }

            return joined;
        }
    }
}

The Index.razor.cs C# code has a few properties that are decorated with the InjectAttribute attribute. This indicates that they will have their implementations resolved from the dependency injection service collection and provided at runtime:

IJSRuntime represents an instance of a JavaScript runtime to which calls may be dispatched.

NavigationManager provides an abstraction for querying and managing URI navigation.

HttpClient provides the ability to send HTTP requests and receive HTTP responses from a resource identified by a URI.

There are several fields that contain application state; such as the rooms that exist, the room name the user has entered, the active room, the active camera, and a SignalR hub connection instance. The OnInitializedAsync is overridden to call the servers api/twilio/rooms endpoint to get the current rooms. Additionally, it instantiates the SignalR hub connection using the MessagePack protocol, with automatic reconnect, and registers a listener on the “rooms updated” endpoint, just before starting the connection.

The corresponding Razor markup is in the Client/Pages/Index.razor file. Replace its contents with the following Razor markup:

@page "/"
@using Blazor.Twilio.Video.Client.Components

<div class="row h-100 pt-5">
    <div class="col-3">
        <div class="jumbotron p-4">
            <Cameras CameraChanged="OnCameraChanged" />
            <h5><i class="fas fa-video"></i> Rooms</h5>
            <div class="list-group">
                <div class="list-group-item d-flex justify-content-between align-items-center">
                    <div class="input-group">
                        <input type="text" class="form-control form-control-lg"
                               placeholder="Room name" aria-label="Room Name" disabled="@(_activeCamera is null)"
                               @bind="_roomName" @onkeydown="@(async args => await TryAddRoom(args))" />
                        <div class="input-group-append">
                            <button class="btn btn-lg twilio-btn-red"
                                    disabled="@(_activeCamera is null || _roomName is null)"
                                    @onclick="@(async args => await TryAddRoom(args))">
                                <i class="far fa-plus-square" aria-label="Create room"></i> Create
                            </button>
                        </div>
                    </div>
                </div>
                @if (!(_rooms?.Any() ?? false))
                {
                    <div class="list-group-item d-flex justify-content-between align-items-center">
                        <p class="lead mb-0">
                            Add a room to begin. Other online participants can join or create rooms.
                        </p>
                    </div>
                }
                else
                {
                    @foreach (var room in _rooms!)
                    {
                        <a href="#" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center
                           @(room.Name == _activeRoom ? "active" : null)"
                           @onclick="@(async _ => await TryJoinRoom(room.Name))">
                            @room.Name
                            <span class="badge badge-primary badge-pill">
                                @($"{room.ParticipantCount} / {room.MaxParticipants}")
                            </span>
                        </a>
                    }
                }

                @if (_activeRoom != null)
                {
                    <div class="list-group-item d-flex justify-content-between align-items-center">
                        <button class="btn btn-lg twilio-btn-red w-100" @onclick="@(async _ => await OnLeaveRoom())">Leave Room?</button>
                    </div>
                }
            </div>
        </div>
    </div>
    <div class="col-9">
        <div id="participants" class="participants-grid">
            <div class="embed-responsive embed-responsive-16by9">
                <div id="camera" class="embed-responsive-item"></div>
            </div>
        </div>
    </div>
</div>

The markup includes a non-standard HTML element named <Cameras />. This element is a Razor component and contains its own C# and Razor markup. The camera component exposes an event named, CameraChanged. The event fires when the camera selection is made from the camera component.

Add the following C# code to the Client/Components/Camera.razor.cs file:

using Blazor.Twilio.Video.Client.Interop;
using Blazor.Twilio.Video.Shared;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Threading.Tasks;

namespace Blazor.Twilio.Video.Client.Components
{
    public partial class Cameras
    {
        [Inject]
        protected IJSRuntime? JavaScript { get; set; }

        [Parameter]
        public EventCallback<string> CameraChanged { get; set; }

        protected Device[]? Devices { get; private set; }
        protected CameraState State { get; private set; }
        protected bool HasDevices => State == CameraState.FoundCameras;
        protected bool IsLoading => State == CameraState.LoadingCameras;

        string? _activeCamera;

        protected override async Task OnInitializedAsync()
        {
            Devices = await JavaScript.GetVideoDevicesAsync();
            State = Devices != null && Devices.Length > 0
                    ? CameraState.FoundCameras
                    : CameraState.Error;
        }

        protected async ValueTask SelectCamera(string deviceId)
        {
            await JavaScript.StartVideoAsync(deviceId, "#camera");
            _activeCamera = deviceId;

            if (CameraChanged.HasDelegate)
            {
                await CameraChanged.InvokeAsync(_activeCamera);
            }
        }
    }
}

Blazor uses the Razor view engine to compile views. The Razor markup creates a template where C# data model binding takes place.

Note that the Cameras component is a partial class. This is needed Because the Razor markup actually compiles to a class named Cameras you need to declare your Cameras.razor.cs class as partial.

Add the following Razor markup to the Client/Components/Camera.razor file:

<h5><i class="fas fa-cog"></i> Settings</h5>
<div class="dropdown pb-4">
    <button class="btn btn-lg btn-secondary dropdown-toggle twilio-btn-red w-100"
            type="button" id="dropdownMenuButton"
            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
        <span>@(IsLoading ? "Loading cameras..." : "Select Camera")</span>
        @if (IsLoading)
        {
            <span id="loading" class="spinner-border spinner-border-sm"
                  role="status" aria-hidden="true"></span>
        }
    </button>
    <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
        @if (HasDevices)
        {
            foreach (var device in Devices!)
            {
                <a href="#" class="dropdown-item @(_activeCamera == device.DeviceId ? "active" : "")"
                   id="@device.DeviceId"
                   @onclick="@(async () => await SelectCamera(device.DeviceId))">
                    @device.Label
                </a>
            }
        }
    </div>
</div>

The Cameras component markup includes a dropdown menu that enables the user to select a camera. When the component is initialized, the devices are retrieved from the JavaScript interop functionality encapsulated in the VideoJS class. When a camera is selected its device identifier is used to start the live camera preview.

If you recall from the Index page, the Cameras component was positioned above the rooms layout and input controls. When the app is running it looks like this:

Cameras component screenshot

A world without JavaScript

While this is a clickbait-worthy heading, it’s not true. Here’s the JavaScript code that’s loved by WebAssembly and for which the VideoJS interop class bears billets doux between it and the WebAssembly code.

Add the following JavaScript code to the Client/wwwroot/site.js file:

let _videoTrack = null;
let _activeRoom = null;
let _participants = new Map();
let _dominantSpeaker = null;

async function getVideoDevices() {
    try {
        let devices = await navigator.mediaDevices.enumerateDevices();
        if (devices.every(d => !d.label)) {
            await navigator.mediaDevices.getUserMedia({
                video: true
            });
        }

        devices = await navigator.mediaDevices.enumerateDevices();
        if (devices && devices.length) {
            const deviceResults = [];
            devices.filter(device => device.kind === 'videoinput')
                .forEach(device => {
                    const { deviceId, label } = device;
                    deviceResults.push({ deviceId, label });
                });

            return deviceResults;
        }
    } catch (error) {
        console.log(error);
    }

    return [];
}

async function startVideo(deviceId, selector) {
    const cameraContainer = document.querySelector(selector);
    if (!cameraContainer) {
        return;
    }

    try {
        if (_videoTrack) {
            _videoTrack.detach().forEach(element => element.remove());
        }

        _videoTrack = await Twilio.Video.createLocalVideoTrack({ deviceId });
        const videoEl = _videoTrack.attach();
        cameraContainer.append(videoEl);
    } catch (error) {
        console.log(error);
    }
}

async function createOrJoinRoom(roomName, token) {
    try {
        if (_activeRoom) {
            _activeRoom.disconnect();
        }

        const audioTrack = await Twilio.Video.createLocalAudioTrack();
        const tracks = [audioTrack, _videoTrack];
        _activeRoom = await Twilio.Video.connect(
            token, {
            name: roomName,
            tracks,
            dominantSpeaker: true
        });

        if (_activeRoom) {
            initialize(_activeRoom.participants);
            _activeRoom
                .on('disconnected',
                    room => room.localParticipant.tracks.forEach(
                        publication => detachTrack(publication.track)))
                .on('participantConnected', participant => add(participant))
                .on('participantDisconnected', participant => remove(participant))
                .on('dominantSpeakerChanged', dominantSpeaker => loudest(dominantSpeaker));
        }
    } catch (error) {
        console.error(`Unable to connect to Room: ${error.message}`);
    }

    return !!_activeRoom;
}

function initialize(participants) {
    _participants = participants;
    if (_participants) {
        _participants.forEach(participant => registerParticipantEvents(participant));
    }
}

function add(participant) {
    if (_participants && participant) {
        _participants.set(participant.sid, participant);
        registerParticipantEvents(participant);
    }
}

function remove(participant) {
    if (_participants && _participants.has(participant.sid)) {
        _participants.delete(participant.sid);
    }
}

function loudest(participant) {
    _dominantSpeaker = participant;
}

function registerParticipantEvents(participant) {
    if (participant) {
        participant.tracks.forEach(publication => subscribe(publication));
        participant.on('trackPublished', publication => subscribe(publication));
        participant.on('trackUnpublished',
            publication => {
                if (publication && publication.track) {
                    detachRemoteTrack(publication.track);
                }
            });
    }
}

function subscribe(publication) {
    if (isMemberDefined(publication, 'on')) {
        publication.on('subscribed', track => attachTrack(track));
        publication.on('unsubscribed', track => detachTrack(track));
    }
}

function attachTrack(track) {
    if (isMemberDefined(track, 'attach')) {
        const audioOrVideo = track.attach();
        audioOrVideo.id = track.sid;

        if ('video' === audioOrVideo.tagName.toLowerCase()) {
            const responsiveDiv = document.createElement('div');
            responsiveDiv.id = track.sid;
            responsiveDiv.classList.add('embed-responsive');
            responsiveDiv.classList.add('embed-responsive-16by9');

            const responsiveItem = document.createElement('div');
            responsiveItem.classList.add('embed-responsive-item');

            // Similar to.
            // <div class="embed-responsive embed-responsive-16by9">
            //   <div id="camera" class="embed-responsive-item">
            //     <video></video>
            //   </div>
            // </div>
            responsiveItem.appendChild(audioOrVideo);
            responsiveDiv.appendChild(responsiveItem);
            document.getElementById('participants').appendChild(responsiveDiv);
        } else {
            document.getElementById('participants')
                .appendChild(audioOrVideo);
        }
    }
}

function detachTrack(track) {
    if (this.isMemberDefined(track, 'detach')) {
        track.detach()
            .forEach(el => {
                if ('video' === el.tagName.toLowerCase()) {
                    const parent = el.parentElement;
                    if (parent && parent.id !== 'camera') {
                        const grandParent = parent.parentElement;
                        if (grandParent) {
                            grandParent.remove();
                        }
                    }
                } else {
                    el.remove()
                }
            });
    }
}

function isMemberDefined(instance, member) {
    return !!instance && instance[member] !== undefined;
}

async function leaveRoom() {
    try {
        if (_activeRoom) {
            _activeRoom.disconnect();
            _activeRoom = null;
        }

        if (_participants) {
            _participants.clear();
        }
    }
    catch (error) {
        console.error(error);
    }
}

window.videoInterop = {
    getVideoDevices,
    startVideo,
    createOrJoinRoom,
    leaveRoom
};

You may see some ESLint warning messages regarding _videoTrack and _activeRoom properties. The app can only call createOrJoinRoom when there is a local _videoTrack. When this call is being made it is not possible for the other to be made at the same time. So you can ignore the warning.

The .site.js JavaScript file provides a number of key functions:

  • Exposing video devices
  • Starting video preview
  • Creating or joining a room
  • Leaving a room

All of the functionality is exposed by window.videoInterop. The four functions are represented by the object literal, and there is very little state to be maintained.

Exposing video devices

The getVideoDevices function asynchronously asks the navigator.mediaDevices to getenumerableDevices.

If the devices list returned is empty, that means the function needs to prompt the user for permission to use their webcam and microphone. The function explicitly asks for user media as follows:

await navigator.mediaDevices.getUserMedia({
    video: true
});

The user is prompted for permission as part of getUserMedia, then the devices are enumerated once more to populate the devices list. The array of devices is then returned to the Blazor WebAssembly caller. The results are serialized from an array of JavaScript literal objects that have deviceId and label members to an array of the C# Device class.

Starting video preview

The startVideo function accepts the deviceId the user selected from the Camera component, and a selector which represents the element identifier of the camera container. If there was a previous _videoTrack it is detached and recreated using the Twilio.Video.createLocalVideoTrack function. It is then attached and appended to the camera container. This will serve as the client’s local video preview stream.

Creating or join a room

The createOrJoinRoom function takes a roomName and token. The token is the TwilioJwt which is resolved from the servers api/twilio/token endpoint. With these two arguments, and the _videoTrack in context, the function can call Twilio.Video.createLocalAudioTrack to get everything it needs to connect to the room.

The token and an options object containing the local audio and video tracks, and the room name are used to call Twilio.Video.connect. The connect function will return the _activeRoom and exposes a number of events. The following table provides a comprehensive list of each event associated with an SDK resource:

Event Registration

Description

_activeRoom.on('disconnected', room => { });

Occurs when a user leaves the room

_activeRoom.on('participantConnected', participant => { });

Occurs when a new participant joins the room

_activeRoom.on('participantDisconnected', participant => { });

Occurs when a participant leaves the room

participant.on('trackPublished', publication => { });

Occurs when a track publication is published

participant.on('trackUnpublished', publication => { });

Occurs when a track publication is unpublished

publication.on('subscribed', track => { });

Occurs when a track is subscribed

publication.on('unsubscribed', track => { });

Occurs when a track is unsubscribed

Leaving a room

The leaveRoom function checks if the _activeRoom is truthy, and calls disconnect to end the call. Furthermore, the list of _participants is cleared if it was also truthy. All of the resource cleanup occurs implicitly.

Putting it all together and testing the completed app

Phew, this was quite the project! Congratulations on completing this build. Well done!

Now you can run the app and do any necessary debugging.

Configuring your test environment

If you’re using Visual Studio 2019 there are a few things you can do to make your  testing run more smoothly:

  • Set the Blazor.Twilio.Video.Server project as the startup project.
  • Change the web server host from IIS Express to the Kestrel web server. You can do this by changing the dropdown next to the green Run arrow from IIS Express to Blazor.Twilio.Video.Server.

Using the Kestrel web server will open a console window which will provide you with additional debugging information. It also enables you to run ASP.NET Core web apps on macOS and Linux, in addition to Windows.

The settings for running the app can be found in:

Blazor.Twilio.Video/Server/Properties/launchSettings.json

If you’re using Visual Studio Code, be sure:

The settings for running the app can be found in:

Blazor.Twilio.Video/.vscode/launch.json

Testing the video chat app

Run the application.

If you’re working in Visual Studio 2019 the app should open the browser you’ve selected and go to https://localhost:5001.

If you’re using Visual Studio Code, open a browser and go to: https://localhost:5001 (or the port you’ve configured) with your browser.

You should see a large “Loading…” message.

After the application loads your browser will prompt you to allow camera access: Grant it.

Select a video device from the list in the user interface, similar to the one shown below:

Web browser screenshot

Once you’ve selected a video device in the app your browser may prompt you to select an audio device.

If you have two video sources on your computer, open an incognito window or a different browser and select a different video device from the one you selected in the first browser window..

Note: Chrome and Firefox handle video devices a bit differently, so the behavior you see may be slightly different depending on the browser(s) you are using.

Enter a name for a video chat room, press Tab, and click Create. You should see the room name added to the list of rooms along with the number of current participants and the maximum number of participants.

When a room is created the local preview is moved just under the settings so that remote room participants join their video stream will render in the larger viewing area.

Note: The grid system in the CSS for this application is set up for a 4 x 4 grid. Adding more than two participants will likely make the layout look odd.

Below is a screen capture showing testing of the final app with two browsers stacked atop each other. You can see the room creator’s view across the top and underneath it the second “participant’s” view with the order of the views reversed.  

Hero image of running application

Author’s note: I’m wearing a Twilio shirt, my tattoos look great, and I’m able to have a conversation with myself.

Editor’s note: Business as usual, really.

Summary

This post showed you how to build a fully functioning video chat application with ASP.NET Core Blazor WebAssembly, SignalR, and Twilio Programmable Video. The Twilio .NET SDK provides JWTs to client-side Blazor frontend code as well as getting room details via the ASP.NET Core Web API. The client-side Blazor SPA integrates the Twilio JavaScript SDK.

Additional resources

The companion repository on GitHub includes persistent selections, which you may want to include in your production app.

To learn more about the tools and technologies used in this tutorial, consult the following fine resources on docs.microsoft.com, the standard-bearer in technical documentation:

To see how to implement video chat with ASP.NET Core and an Angular frontend, check out this post on the Twilio blog, along with its companion post about deploying and running the app on Azure:

Building a Video Chat App with ASP.NET Core 3.1, Angular 9, and Twilio

Running a Video Chat App Built with ASP.NET Core 3.1 and Twilio Programmable Video on Microsoft Azure

Also check out the appearance by Twilio Developer Evangelist Corey Weathers on the .NET Docs show for more “blazing bits”!

David Pine is a 2x Microsoft MVP, Google Developer Expert, Twilio Champion, and international speaker. You can follow him on Twitter at @davidpine7. Be sure to check out his blog at https://davidpine.net.