Application de chat vidéo avec ASP.NET Core 3.1, Angular 9 et Twilio

June 09, 2020
Rédigé par
David Pine
Contributeur
Les opinions exprimées par les contributeurs de Twilio sont les leurs
Révisé par
AJ Saulsberry
Contributeur
Les opinions exprimées par les contributeurs de Twilio sont les leurs

Construction d'une application de chat vidéo avec ASP.NET Core 3.1, Angular 9 et Twilio

L'interaction utilisateur en temps réel est un excellent moyen d'améliorer les capacités de communication et de collaboration d'une application Web. Le chat vidéo est un choix évident pour les sites de vente, de support client et de formation, mais est-il pratique à implémenter ? Si vous développez avec Angular sur le front-end et ASP.NET Core pour le serveur, Twilio Programmable Video vous permet d'ajouter efficacement un chat vidéo robuste à votre application.

Ce post vous montrera comment créer une application de chat vidéo exécutée à l'aide du SDK JavaScript Twilio dans votre application monopage Angular et le SDK Twilio pour C# et .NET dans votre code serveur ASP.NET Core. Vous allez construire les interactions nécessaires pour créer et rejoindre des salles de chat vidéo, publier des pistes audio et vidéo des participants et vous abonner à ces pistes.

Pour savoir comment construire la même application avec les versions précédentes du langage et du framework, consultez les posts suivants :

Pour en savoir plus sur le déploiement de l'application à partir de cette étude de cas sur Microsoft Azure, consultez le post suivant :

Si vous souhaitez voir une intégration complète des API Twilio dans une application .NET Core, consultez cette série de vidéos gratuite en 5 parties. Elle est séparée du tutoriel de ce post, mais vous donnera une vue complète de plusieurs API à la fois.

Conditions préalables

Pour construire l'application décrite dans ce post, vous aurez besoin des ressources suivantes :

Pour tirer le meilleur parti de ce post, vous devez avoir des connaissances quant aux éléments suivants :

  • Angular, y compris les Observables et les Promesses
  • ASP.NET Core, y compris l'injection de dépendance
  • C# 8
  • TypeScript

Un repo git pour ce tutoriel est disponible sur GitHub. Il inclut le code source complet de l'application terminée dans la branche master. Le code pour ASP.NET Core 2.2 et Angular 8 se trouve dans le même répertoire de la branche net2.2. ASP.NET Core 2.2 n'est plus pris en charge par Microsoft.

Présentation du fonctionnement de l'application

L'application IEvangelist.VideoChat utilise les ressources du chat vidéo Twilio. Le fonctionnement de l'application commence lorsqu'un utilisateur crée une salle de chat vidéo par le biais du front-end Angular. À partir de là, deux processus parallèles se produisent. La SPA front-end exécutée dans le navigateur de l'utilisateur notifie le serveur ASP.NET Core de la nouvelle salle et le serveur notifie les autres clients abonnés de la nouvelle salle.

En même temps, l'utilisateur peut rejoindre la salle qu'il a créée, ou une salle instanciée par un autre utilisateur. Les pistes locales de l'utilisateur sont publiées auprès des autres membres de la salle et l'utilisateur est abonné aux pistes distantes des autres participants de la salle.

Schéma de fonctionnement de l'application de chat vidéo

Mise en route avec Twilio Programmable Video

Vous aurez besoin d'un compte d'essai Twilio gratuit pour construire ce projet. Inscrivez-vous avec ce lien et obtenez un crédit supplémentaire de 10 $ sur votre compte.

Vous aurez également besoin d'un projet Twilio pour construire ce projet avec le SDK Twilio Video. La configuration ne prendra que quelques minutes.

Une fois que vous avez un compte Twilio, accédez à la console Twilio et effectuez les opérations suivantes :

  1. Dans l'accueil du tableau de bord, localisez le Account SID et votre Auth Token en haut à droite et copiez-les dans un endroit sûr.
  2. Sélectionnez la section Programmable Video de la console.
  3. Sous Tools (Outils) > API Keys (Clés API), créez une nouvelle clé API avec le nom convivial de votre choix et copiez le SID et la clé secrète d'API dans un endroit sûr.

Les informations d'identification que vous venez d'acquérir sont des secrets d'utilisateur. Il est donc conseillé de ne pas les stocker dans le code source du projet. Une façon de les protéger et de les rendre accessibles dans la configuration de votre projet consiste à les stocker en tant que variables d'environnement sur votre machine de développement.

ASP.NET Core peut accéder aux variables d'environnement par le biais du package Microsoft.Extensions.Configuration afin qu'elles puissent être utilisées comme propriétés d'un objet IConfiguration dans la classe Startup. Les instructions suivantes vous indiquent comment procéder sous Windows.

Exécutez les commandes suivantes dans l'invite de commande Windows, en remplaçant les espaces réservés par vos informations d'identification. Pour les autres systèmes d'exploitation, utilisez des commandes comparables pour créer les mêmes variables d'environnement.

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

Si vous préférez, ou si votre environnement de développement l'exige, vous pouvez placer ces valeurs dans le fichier appsettings.development.json comme suit, mais veillez à ne pas exposer ce fichier dans un repo de code source ou un autre emplacement facilement accessible.

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

Création de l'application ASP.NET Core

Créez une nouvelle application Web ASP.NET Core nommée « VideoChat » avec .NET Core 3.1 et une modélisation Angular à l'aide de l'interface utilisateur de Visual Studio 2019 ou de l'instruction de ligne de commande dotnet suivante :

dotnet new angular -o VideoChat

Cette commande crée une solution Visual Studio contenant un projet ASP.NET Core configuré pour utiliser une application Angular, ClientApp, comme front-end. Le code côté serveur est écrit en C# et a deux objectifs principaux : en premier lieu, il sert l'application web Angular, la mise en page HTML, le CSS et le code JavaScript. Deuxièmement, il agit comme une API Web. L'application côté client a la logique pour présenter comment créer et rejoindre les salles de chat vidéo, et elle héberge le flux vidéo des participants pour les chats vidéo en direct.

Ajout du SDK Twilio pour C# et .NET

L'application serveur ASP.NET Core utilisera le SDK Twilio pour C# et .NET. Installez cela avec NuGet Package Manager, Package Manager Console ou l'instruction de ligne de commande dotnet suivante :

dotnet add package Twilio

Le fichier VideoChat.csproj doit inclure les références de package dans un nœud <ItemGroup>, comme illustré ci-dessous, si la commande s'est terminée avec succès. (Les numéros de version de votre projet peuvent être plus élevés.)

<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.1.4" />
    <PackageReference Include="Twilio" Version="5.42.0" />
  </ItemGroup>

Création de la structure de dossiers et de fichiers

Créez les dossiers et fichiers suivants sous la racine du projet IEvangelist.VideoChat :

/Abstractions
   IVideoService.cs
/Hubs
   NotificationHub.cs
/Models
   RoomDetails.cs
/Options
   TwilioSettings.cs
/Services
   VideoService.cs

Lorsque vous avez terminé, la structure des dossiers et des fichiers de l'explorateur de solutions doit ressembler à ce qui suit :

Capture d&#x27;écran Explorateur de solutions Visual Studio 2019

Dans le répertoire /Controllers, renommez SampleDataController.cs en VideoController.cs et mettez à jour le nom de classe pour qu'il corresponde au nouveau nom de fichier.

Création de services

Le code côté serveur doit effectuer plusieurs actions clés, l'une d'entre elles étant de fournir un token Web JSON (JWT) au client pour que le client puisse se connecter à l'API Twilio Programmable Video. Pour ce faire, vous devez disposer de l'Account SID Twilio, de la clé API et de la clé secrète API que vous avez stockés en tant que variables d'environnement. Dans ASP.NET Core, il est courant d'exploiter une classe C# fortement typée qui représentera les différents paramètres.

Ajoutez le code C# suivant au fichier Options/TwilioSettings.cs sous les directives using :

namespace VideoChat.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; }
    }
}

Ces paramètres sont configurés dans la méthode Startup.ConfigureServices, qui mappe les valeurs des variables d'environnement et du fichier appsettings.json aux instances IOptions<TwilioSettings> disponibles pour l'injection de dépendance. Dans ce cas, les variables d'environnement sont les seules valeurs requises pour la classe TwilioSettings.

Insérez le code C# suivant dans le fichier Models/RoomDetails.cs sous les directives using :

namespace VideoChat.Models
{
    public class RoomDetails
    {
        public string Id { get; set; }
        public string Name { get; set; }
        public int ParticipantCount { get; set; }
        public int MaxParticipants { get; set; }
    }
}

La classe RoomDetails est un objet qui représente une salle de chat vidéo.

En gardant à l'esprit l'injection de dépendance, créez une abstraction pour le service vidéo côté serveur en tant qu'interface.

Remplacez le contenu du fichier Abstractions/IVideoService.cs par le code C# suivant :

using System.Collections.Generic;
using System.Threading.Tasks;
using VideoChat.Models;

namespace VideoChat.Abstractions
{
    public interface IVideoService
    {
        string GetTwilioJwt(string identity);
        Task<IEnumerable<RoomDetails>> GetAllRoomsAsync();
    }
}

Il s'agit d'une interface très simple qui expose la capacité d'obtenir le JWT lorsqu'une identité lui est donnée. Elle permet également d'obtenir toutes les salles.

Pour implémenter l'interface IVideoService, remplacez le contenu du fichier Services/VideoService.cs par le code suivant :

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VideoChat.Abstractions;
using VideoChat.Models;
using VideoChat.Options;
using Twilio;
using Twilio.Base;
using Twilio.Jwt.AccessToken;
using Twilio.Rest.Video.V1;
using Twilio.Rest.Video.V1.Room;
using ParticipantStatus = Twilio.Rest.Video.V1.Room.ParticipantResource.StatusEnum;

namespace VideoChat.Services
{
    public class VideoService : IVideoService
    {
        readonly TwilioSettings _twilioSettings;

        public VideoService(Microsoft.Extensions.Options.IOptions<TwilioSettings> twilioOptions)
        {
            _twilioSettings =
                twilioOptions?.Value
             ?? throw new ArgumentNullException(nameof(twilioOptions));

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

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

        public async Task<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.ToList().Count
                };
            }
        }
    }
}

Le constructeur de classe VideoService prend une instance IOptions<TwilioSettings> et initialise le TwilioClient à l'aide de la clé API fournie et de la clé secrète API correspondante. Cette opération est effectuée de manière statique et permet une utilisation future de diverses fonctions basées sur les ressources. L'implémentation de GetTwilioJwt est utilisée pour émettre un nouveau Twilio.JWT.AccessToken.Token, à l'aide de l'Account SID, de la clé API, de la clé secrète API, de l'identité et d'une nouvelle instance de HashSet<iGrant> avec un seul objet VideoGrant. Avant le renvoi, une invocation de la fonction .ToJwt convertit l'instance de token en son équivalent string.

La fonction GetAllRoomsAsync renvoie une liste d'objets RoomDetails. Elle commence par attendre la fonction RoomResource.ReadAsync, qui donnera un ResourceSet<RoomResource> le moment venu. À partir de cette liste de salles, le code projette une série de Task<RoomDetails> où il demandera le ResourceSet<ParticipantResource> correspondant actuellement connecté à la salle spécifiée avec l'identifiant de salle, room.UniqueName.

Vous remarquerez peut-être une syntaxe inconnue dans la fonction GetAllRoomsService si vous n'avez pas l'habitude de coder après l'instruction return. C# 8 inclut une fonction locale statique qui permet d'écrire des fonctions dans la portée du corps de la méthode (« localement »), même après l'instruction return. Elles sont statiques pour s'assurer que les variables ne sont pas capturées dans la portée.

Notez que pour chaque salle n qui existe, GetRoomDetailsAsync est appelé pour extraire les participants connectés à la salle. Cela peut être un problème de performance ! Même si cette opération est effectuée de manière asynchrone et parallèle, elle doit être considérée comme un goulet d'étranglement potentiel et marquée pour la refactorisation. Ce n'est pas un problème dans ce projet de démonstration, car il y a quelques salles tout au plus.

Création du contrôleur API

Le contrôleur vidéo fournira deux points de terminaison HTTP GET que le client Angular pourra utiliser.

Point de terminaison

Verbe

Type

Description

api/video/token

GET

JSON

un objet avec un membre token attribué à partir du TWT Twilio

api/video/rooms

GET

JSON

tableau des détails de la salle : 
{ name, participantCount, maxParticipants }

Remplacez le contenu du fichier Controllers/IVideoService.cs par le code C# suivant :

using System.Threading.Tasks;
using VideoChat.Abstractions;
using Microsoft.AspNetCore.Mvc;

namespace VideoChat.Controllers
{
    [
        ApiController,
        Route("api/video")
    ]
    public class VideoController : ControllerBase
    {
        readonly IVideoService _videoService;

        public VideoController(IVideoService videoService)
            => _videoService = videoService;

        [HttpGet("token")]
        public IActionResult GetToken()
            => new JsonResult(new { token = _videoService.GetTwilioJwt(User.Identity.Name) });

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

Le contrôleur est décoré avec l'attribut ApiController et un attribut Route contenant le modèle api/video.

Dans le constructeur VideoControllerIVideoService est injecté et affecté à une instance de champ readonly.

Création du hub de notification

L'application ASP.NET Core ne serait pas complète sans l'utilisation de SignalR, qui « ...est une bibliothèque open-source qui simplifie l'ajout de fonctionnalités Web en temps réel aux applications. La fonctionnalité Web en temps réel permet au code côté serveur de transmettre instantanément du contenu aux clients. »

Lorsqu'un utilisateur crée une salle dans l'application, son code côté client informe le serveur et, au final les autres clients, de la nouvelle salle. Pour ce faire, vous avez accès à un hub de notification SignalR.

Remplacez le contenu du fichier Hubs/NotificationHub.cs par le code C# suivant :

using System.Threading.Tasks;
using Microsoft.AspNetCore.SignalR;

namespace VideoChat.Hubs
{
    public class NotificationHub : Hub
    {
        public async Task RoomsUpdated(bool flag)
            => await Clients.Others.SendAsync("RoomsUpdated", flag);
    }
}

Le NotificationHub envoie de manière asynchrone un message à tous les autres clients pour les avertir lorsqu'une salle est ajoutée.

Configuration de Startup.cs

Quelques éléments doivent être ajoutés et modifiés dans la classe Startup et dans la méthode ConfigureServices.

Ajoutez les directives using suivantes aux directives existantes en haut de Startup.cs :

using VideoChat.Abstractions;
using VideoChat.Hubs;
using VideoChat.Options;
using VideoChat.Services;

Dans la méthode ConfigureServices, remplacez tout le code existant par le suivant :

services.AddControllersWithViews();

services.Configure<TwilioSettings>(
    settings =>
    {
        settings.AccountSid = Environment.GetEnvironmentVariable("TWILIO_ACCOUNT_SID");
        settings.ApiSecret = Environment.GetEnvironmentVariable("TWILIO_API_SECRET");                        
        settings.ApiKey = Environment.GetEnvironmentVariable("TWILIO_API_KEY");
    })
    .AddTransient<IVideoService, VideoService>()
    .AddSpaStaticFiles(config => config.RootPath = "ClientApp/dist");

services.AddSignalR();

Cela configure les paramètres de l'application contenant les informations d'identification de l'API Twilio, mappe l'abstraction du service vidéo à son implémentation correspondante, attribue le chemin racine à la SPA et ajoute SignalR.

Dans la méthode Configure, remplacez l'appel app.UseEndpoints par les lignes suivantes :

app.UseEndpoints(
    endpoints =>
    {
        endpoints.MapControllerRoute(
            name: "default",
            pattern: "{controller}/{action=Index}/{id?}");

        endpoints.MapHub<NotificationHub>("/notificationHub");
    })

Cette opération mappe le point de terminaison de notification sur l'implémentation de NotificationHub. À l'aide de ce point de terminaison, la SPA Angular exécutée dans les navigateurs clients peut envoyer des messages à tous les autres clients. SignalR fournit l'infrastructure de notification pour ce processus.

La configuration côté serveur est terminée. Compilez le projet et assurez-vous qu'il n'y a pas d'erreurs.

Construction de l'application Angular côté client

Les modèles ASP.NET Core ne sont pas mis à jour régulièrement et Angular est constamment mis à jour. Pour construire l'application client avec le code Angular le plus récent, il est préférable de commencer par un modèle Angular actuel.

Supprimez le répertoire ClientApp du projet IEvangelist.VideoChat.

Ouvrez une fenêtre de console (PowerShell ou Windows Console) dans le répertoire de projet VideoChat et exécutez la commande CLI Angular suivante :

ng n ClientApp --style css --routing false --minimal true --skipTests true

Cette commande doit créer un nouveau dossier ClientApp dans le projet VideoChat avec la structure de base des dossiers et des fichiers pour une application Angular.

L'application Angular a un certain nombre de dépendances, y compris les packages twilio-video et @microsoft/signalr. Ses dépendances de développement incluent les définitions de type pour @types/twilio-video.

Remplacez le contenu de package.json par le code JSON suivant :

{
  "name": "ievangelist-videochat",
  "version": "1.0.0",
  "license": "MIT",
  "scripts": {
    "ng": "ng",
    "start": "ng serve --verbose",
    "build": "ng build"
  },
  "private": true,
  "dependencies": {
    "@angular/animations": "9.1.11",
    "@angular/common": "9.1.11",
    "@angular/compiler": "9.1.11",
    "@angular/core": "9.1.11",
    "@angular/forms": "9.1.11",
    "@angular/platform-browser": "9.1.11",
    "@angular/platform-browser-dynamic": "9.1.11",
    "@angular/platform-server": "9.1.11",
    "@angular/router": "9.1.11",
    "@nguniversal/module-map-ngfactory-loader": "7.0.2",
    "@microsoft/signalr": "3.0.1",
    "aspnet-prerendering": "^3.0.1",
    "core-js": "^2.6.1",
    "tslib": "^1.10.0",
    "twilio-video": "2.0.0-beta15",
    "rxjs": "^6.5.3",
    "zone.js": "~0.10.2"
  },
  "devDependencies": {
    "@angular-devkit/build-angular": "~0.901.8",
    "@angular/cli": "9.1.8",
    "@angular/compiler-cli": "9.1.11",
    "@angular/language-service": "9.1.11",
    "@types/node": "^12.11.1",
    "@types/twilio-video": "^2.0.9",
    "codelyzer": "^5.1.2",
    "protractor": "^7.0.0",
    "ts-node": "~7.0.1",
    "tslint": "~5.12.0",
    "typescript": "3.8.3"
  }
}

Une fois les mises à jour du fichier package.json terminées, exécutez l'instruction de ligne de commande npm suivante dans le répertoire ClientApp :

npm install

Cette commande garantit que toutes les dépendances JavaScript requises sont téléchargées et installées.

Ouvrez le fichier /ClientApp/src/index.html et notez l'élément <app-root>. Cet élément non standard est utilisé par Angular pour afficher l'application Angular sur la page HTML. L'élément app-root est le sélecteur du composant AppComponent.

Ajoutez le balisage HTML suivant au fichier index.html, dans l'élément <head> situé sous <link> du favicon :

<link rel="stylesheet"
      href="https://use.fontawesome.com/releases/v5.11.2/css/all.css"
     integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/"
      crossorigin="anonymous">
<link rel="stylesheet"
      href="https://bootswatch.com/4/darkly/bootstrap.min.css">

Ce balisage permet à l'application d'utiliser la version gratuite de Font Awesome et le thème Bootswatch Darkly.

Passez au fichier /src/app/app.component.html et remplacez le contenu par le balisage HTML suivant :

<app-home></app-home>

À partir de la ligne de commande du répertoire ClientApp, exécutez les commandes CLI Angular suivantes pour générer les composants :

ng g c camera --nospec
ng g c home --nospec
ng g c participants --nospec
ng g c rooms --nospec
ng g c settings --nospec
ng g c settings/device-select --nospec --flat true

Exécutez ensuite les commandes CLI Angular suivantes pour générer les services requis :

ng g s services/videochat --nospec
ng g s services/device --nospec

Ces commandes ajoutent tout le code passe-partout, ce qui vous permet de vous concentrer sur l'implémentation de l'application. Elles ajoutent de nouveaux composants et services et mettent à jour le fichier app.module.ts en important et en déclarant les composants créés par les commandes.

La structure de dossiers et de fichiers doit être :

/ClientApp/src/app
  /activity-indicator
     activity-indicator.component.html
     activity-indicator.component.ts
  /camera
      camera.component.css
      camera.component.html
      camera.component.ts
   /home
      home.component.css
      home.component.html
      home.component.ts
  /participants
      participants.component.css
      participants.component.html
      participants.component.ts
  /rooms
      rooms.component.css
      rooms.component.html
      rooms.component.ts
  /services
     devices.service.ts
     storage.service.ts
     videochat.service.ts
  /settings
     device-select.component.html
     device-select.component.ts
     settings.component.css
     settings.component.html
     settings.component.ts
  app.component.css
  app.component.html
  app.component.ts
  app.module.ts

Dans l'explorateur de solutions Visual Studio, elle ressemble à la capture d'écran suivante :

Capture d&#x27;écran Panneau de l&#x27;explorateur de solutions Visual Studio 2019

Mise à jour du module d'application Angular

L'application s'appuie sur deux modules supplémentaires, l'un pour implémenter des formulaires et l'autre pour utiliser HTTP.

Ajoutez les deux instructions d'importation suivantes en haut de ClientApp/src/app/app.module.ts:

import { FormsModule } from '@angular/forms';
import { HttpClientModule } from '@angular/common/http';

Ajoutez ensuite ces modules au tableau imports de @NgModule comme suit :

imports: [
  BrowserModule,
  HttpClientModule,
  FormsModule
],

Ajout d'émulateurs de navigateur Web JavaScript

Un projet JavaScript ne serait pas complet sans émulateur de navigateur Web, n'est-ce pas ? Angular ne fait pas exception. Heureusement, l'outillage Angular fournit un fichier d'émulateur de navigateur Web.

Ajoutez le JavaScript suivant au bas du fichier ClientApp/src/polyfill.ts existant :

// https://github.com/angular/angular-cli/issues/9827#issuecomment-386154063
// Add global to window, assigning the value of window itself.
(window as any).global = window;

Création de services Angular

La classe DeviceService fournit des informations sur les périphériques multimédias utilisés dans l'application, notamment leur disponibilité et si l'utilisateur a accordé à l'application l'autorisation de les utiliser.

Remplacez le contenu du fichier services/device.service.ts par le code TypeScript suivant :

import { Injectable } from '@angular/core';
import { ReplaySubject, Observable } from 'rxjs';

export type Devices = MediaDeviceInfo[];

@Injectable({
    providedIn: 'root'
})
export class DeviceService {
    $devicesUpdated: Observable<Promise<Devices>>;

    private deviceBroadcast = new ReplaySubject<Promise<Devices>>();

    constructor() {
        if (navigator && navigator.mediaDevices) {
            navigator.mediaDevices.ondevicechange = (_: Event) => {
                this.deviceBroadcast.next(this.getDeviceOptions());
            }
        }

        this.$devicesUpdated = this.deviceBroadcast.asObservable();
        this.deviceBroadcast.next(this.getDeviceOptions());
    }

    private async isGrantedMediaPermissions() {
        if (navigator && navigator.userAgent && navigator.userAgent.indexOf('Chrome') < 0) {
            return true; // Follows standard workflow for non-Chrome browsers.
        }

        if (navigator && navigator['permissions']) {
            try {
                const result = await navigator['permissions'].query({ name: 'camera' });
                if (result) {
                    if (result.state === 'granted') {
                        return true;
                    } else {
                        const isGranted = await new Promise<boolean>(resolve => {
                            result.onchange = (_: Event) => {
                                const granted = _.target['state'] === 'granted';
                                if (granted) {
                                    resolve(true);
                                }
                            }
                        });

                        return isGranted;
                    }
                }
            } catch (e) {
                // This is only currently supported in Chrome.
                // https://stackoverflow.com/a/53155894/2410379
                return true;
            }
        }

        return false;
    }

    private async getDeviceOptions(): Promise<Devices> {
        const isGranted = await this.isGrantedMediaPermissions();
        if (navigator && navigator.mediaDevices && isGranted) {
            let devices = await this.tryGetDevices();
            if (devices.every(d => !d.label)) {
                devices = await this.tryGetDevices();
            }
            return devices;
        }

        return null;
    }

    private async tryGetDevices() {
        const mediaDevices = await navigator.mediaDevices.enumerateDevices();
        const devices = ['audioinput', 'audiooutput', 'videoinput'].reduce((options, kind) => {
            return options[kind] = mediaDevices.filter(device => device.kind === kind);
        }, [] as Devices);

        return devices;
    }
}

Ce service fournit un Observable mediaDevices auquel les auditeurs concernés peuvent s'abonner. Lorsque les informations sur les périphériques multimédias changent, comme le débranchement ou le branchement d'une webcam USB, ce service avertit tous les auditeurs. Il tente également d'attendre que l'utilisateur accorde des autorisations aux différents périphériques multimédias utilisés par le SDK twilio-video.

Le VideoChatService est utilisé pour accéder aux points de terminaison de l'API Web ASP.NET Core côté serveur. Il expose la capacité d'obtenir la liste des salles et de créer ou de rejoindre une salle nommée.

Remplacez le contenu du fichier services/videochat.service.ts par le code TypeScript suivant :

import { connect, ConnectOptions, LocalTrack, Room } from 'twilio-video';
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { ReplaySubject , Observable } from 'rxjs';

interface AuthToken {
    token: string;
}

export interface NamedRoom {
    id: string;
    name: string;
    maxParticipants?: number;
    participantCount: number;
}

export type Rooms = NamedRoom[];

@Injectable({
    providedIn: 'root'
})
export class VideoChatService {
    $roomsUpdated: Observable<boolean>;

    private roomBroadcast = new ReplaySubject<boolean>();

    constructor(private readonly http: HttpClient) {
        this.$roomsUpdated = this.roomBroadcast.asObservable();
    }

    private async getAuthToken() {
        const auth =
            await this.http
                      .get<AuthToken>(`api/video/token`)
                      .toPromise();

        return auth.token;
    }

    getAllRooms() {
        return this.http
                   .get<Rooms>('api/video/rooms')
                   .toPromise();
    }

    async joinOrCreateRoom(name: string, tracks: LocalTrack[]) {
        let room: Room = null;
        try {
            const token = await this.getAuthToken();
            room =
                await connect(
                    token, {
                        name,
                        tracks,
                        dominantSpeaker: true
                    } as ConnectOptions);
        } catch (error) {
            console.error(`Unable to connect to Room: ${error.message}`);
        } finally {
            if (room) {
                this.roomBroadcast.next(true);
            }
        }

        return room;
    }

    nudge() {
        this.roomBroadcast.next(true);
    }
}

Notez que la récupération du Twilio JWT est marquée comme private. La méthode getAuthToken n'est utilisée dans la classe VideoChatService que pour l'appel de connect à partir du module twilio-video, qui s'effectue de manière asynchrone dans la méthode JoinOrCreateRoom.

Présentation de l'interaction des entités de chat vidéo

Maintenant que les services de base sont en place, comment doivent-ils interagir les uns avec les autres et comment doivent-ils se comporter ? Les utilisateurs doivent pouvoir créer ou rejoindre des salles. Une salle est une ressource Twilio, et une salle peut avoir un ou plusieurs participants. Un participant est également une ressource de Twilio. De même, les participants ont des publications de piste qui donnent accès à des pistes vidéo et audio. Les participants et les salles partagent des caméras qui fournissent des publications de piste pour les pistes audio et vidéo. L'application comporte des composants Angular pour chacun de ces éléments.

Implémentation du composant Camera

En plus de fournir des pistes audio et vidéo que les participants de la salle peuvent partager, le CameraComponent affiche également un aperçu de la caméra locale en effectuant un rendu des pistes audio et vidéo créées localement sur le DOM en tant qu'éléments <app-camera>. Le SDK de la plate-forme JavaScript Twilio Programmable Video, importé de twilio-video, fournit une API facile à utiliser pour créer et gérer les pistes locales.

Remplacez le contenu du fichier camera/camera.component.ts par le code TypeScript suivant :

import { Component, ElementRef, ViewChild, AfterViewInit, Renderer2 } from '@angular/core';
import { createLocalVideoTrack, LocalVideoTrack } from 'twilio-video';
import { StorageService } from '../services/storage.service';

@Component({
    selector: 'app-camera',
    styleUrls: ['./camera.component.css'],
    templateUrl: './camera.component.html',
})
export class CameraComponent implements AfterViewInit {
    @ViewChild('preview', { static: false }) previewElement: ElementRef;

    isInitializing: boolean = true;
    videoTrack: LocalVideoTrack = null;

    constructor(
        private readonly storageService: StorageService,
        private readonly renderer: Renderer2) { }

    async ngAfterViewInit() {
        if (this.previewElement && this.previewElement.nativeElement) {
            const selectedVideoInput = this.storageService.get('videoInputId');
            await this.initializeDevice(selectedVideoInput);
        }
    }

    async initializePreview(deviceId: string) {
        await this.initializeDevice(deviceId);
    }

    finalizePreview() {
        try {
            if (this.videoTrack) {
                this.videoTrack.detach().forEach(element => element.remove());
            }
            this.videoTrack = null;
        } catch (e) {
            console.error(e);
        }
    }

    private async initializeDevice(deviceId?: string) {
        try {
            this.isInitializing = true;

            this.finalizePreview();

            this.videoTrack = deviceId
                ? await createLocalVideoTrack({ deviceId })
                : await createLocalVideoTrack();

            const videoElement = this.videoTrack.attach();
            this.renderer.setStyle(videoElement, 'height', '100%');
            this.renderer.setStyle(videoElement, 'width', '100%');
            this.renderer.appendChild(this.previewElement.nativeElement, videoElement);
        } finally {
            this.isInitializing = false;
        }
    }
}

Remplacez le contenu du fichier camera/camera.component.html par le balisage HTML suivant :

<div id="preview" #preview>
    <div *ngIf="isInitializing">Loading preview... Please wait.</div>
</div>

Dans le code TypeScript ci-dessus, le décorateur Angular @ViewChild est utilisé pour obtenir une référence à l'élément HTML #preview utilisé dans la vue. Avec la référence à l'élément, le SDK JavaScript Twilio peut créer des pistes audio et vidéo locales associées au périphérique.

Une fois les pistes créées, le code trouve la piste vidéo et l'ajoute à l'élément #preview. Le résultat est un flux vidéo en direct rendu sur la page HTML.

Implémentation du composant Room

Le composant RoomsComponent fournit une interface permettant aux utilisateurs de créer des salles en saisissant un roomName par le biais d'un élément <input type=’text’> et d'un élément <button> lié à la méthode onTryAddRoom de la classe. L'interface utilisateur se présente comme suit :

Capture d&#x27;écran Composant Room

Au fur et à mesure que les utilisateurs ajoutent des salles, la liste des salles existantes s'affiche sous les commandes de création de salles. Le nom de chaque salle existante s'affiche avec le nombre de participants actifs et la capacité de la salle, comme dans l'exemple ci-dessous.

Capture d&#x27;écran Composant Room après l&#x27;ajout d&#x27;une salle

Pour implémenter l'interface utilisateur des salles, remplacez le balisage dans le fichier rooms/rooms.component.html par le balisage HTML suivant :

<div class="jumbotron">
    <h5 class="display-4"><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"
                       [(ngModel)]="roomName" (keydown.enter)="onTryAddRoom()">
                <div class="input-group-append">
                    <button class="btn btn-lg btn-outline-secondary twitter-red"
                            type="button" [disabled]="!roomName"
                            (click)="onAddRoom(roomName)">
                        <i class="far fa-plus-square"></i> Create
                    </button>
                </div>
            </div>
        </div>
        <div *ngIf="!rooms || !rooms.length" class="list-group-item d-flex justify-content-between align-items-center">
            <p class="lead">
                Add a room to begin. Other online participants can join or create rooms.
            </p>
        </div>
        <a href="#" *ngFor="let room of rooms"
           (click)="onJoinRoom(room.name)" [ngClass]="{ 'active': activeRoomName === room.name }"
           class="list-group-item list-group-item-action d-flex justify-content-between align-items-center">
            {{ room.name }}
            <span class="badge badge-primary badge-pill">
                {{ room.participantCount }} / {{ room.maxParticipants }}
            </span>
        </a>
    </div>
</div>

L'élément RoomsComponent s'abonne à l'Observable videoChatService.$roomsUpdated. Chaque fois qu'une salle est créée, RoomsComponent signale sa création par le biais de l'Observable et le service NotificationHub est à l'écoute. À l'aide de SignalR, l'élément NotificationHub renvoie ce message à tous les autres clients connectés. Ce mécanisme permet au code côté serveur de fournir des fonctionnalités Web en temps réel aux applications clientes. Dans cette application, l'élément RoomsComponent met automatiquement à jour la liste des salles disponibles.

Pour implémenter la fonctionnalité RoomsComponent, remplacez le contenu du fichier rooms/rooms.component.ts par le code TypeScript suivant :

import { Component, OnInit, OnDestroy, EventEmitter, Output, Input } from '@angular/core';
import { Subscription } from 'rxjs';
import { tap } from 'rxjs/operators';
import { NamedRoom, VideoChatService } from '../services/videochat.service';

@Component({
    selector: 'app-rooms',
    styleUrls: ['./rooms.component.css'],
    templateUrl: './rooms.component.html',
})
export class RoomsComponent implements OnInit, OnDestroy {
    @Output() roomChanged = new EventEmitter<string>();
    @Input() activeRoomName: string;

    roomName: string;
    rooms: NamedRoom[];

    private subscription: Subscription;

    constructor(
        private readonly videoChatService: VideoChatService) { }

    async ngOnInit() {
        await this.updateRooms();
        this.subscription =
            this.videoChatService
                .$roomsUpdated
                .pipe(tap(_ => this.updateRooms()))
                .subscribe();
    }

    ngOnDestroy() {
        if (this.subscription) {
            this.subscription.unsubscribe();
        }
    }

    onTryAddRoom() {
        if (this.roomName) {
            this.onAddRoom(this.roomName);
        }
    }

    onAddRoom(roomName: string) {
        this.roomName = null;
        this.roomChanged.emit(roomName);
    }

    onJoinRoom(roomName: string) {
        this.roomChanged.emit(roomName);
    }

    async updateRooms() {
        this.rooms = (await this.videoChatService.getAllRooms()) as NamedRoom[];
    }
}

En arrière-plan, lorsqu'un utilisateur sélectionne une salle à rejoindre ou crée une salle, il se connecte à cette salle via le SDK twilio-vidéo.

L'élément RoomsComponent attend un nom de salle et un tableau d'objets LocalTrack. Ces pistes locales proviennent de l'aperçu de la caméra locale, qui fournit à la fois une piste audio et une piste vidéo. Les objets LocalTrack sont publiés dans les salles qu'un utilisateur a rejoint afin que les autres participants puissent s'abonner et les recevoir.

Implémentation du composant Participants

Qu'est-ce qu'une salle sans participants ? C'est juste une pièce vide, ce n'est pas amusant !

Mais les salles ont quelque chose de très cool : elles étendent l'élément EventEmitter. Cela signifie qu'une salle permet l'enregistrement des auditeurs d'événements.

Pour implémenter l'élément ParticipantsComponent, remplacez le contenu du fichier participants/participants.component.ts par le code TypeScript suivant :

import {
    Component,
    ViewChild,
    ElementRef,
    Output,
    Input,
    EventEmitter,
    Renderer2
} from '@angular/core';
import {
    Participant,
    RemoteTrack,
    RemoteAudioTrack,
    RemoteVideoTrack,
    RemoteParticipant,
    RemoteTrackPublication
} from 'twilio-video';

@Component({
    selector: 'app-participants',
    styleUrls: ['./participants.component.css'],
    templateUrl: './participants.component.html',
})
export class ParticipantsComponent {
    @ViewChild('list', { static: false }) listRef: ElementRef;
    @Output('participantsChanged') participantsChanged = new EventEmitter<boolean>();
    @Output('leaveRoom') leaveRoom = new EventEmitter<boolean>();
    @Input('activeRoomName') activeRoomName: string;

    get participantCount() {
        return !!this.participants ? this.participants.size : 0;
    }

    get isAlone() {
        return this.participantCount === 0;
    }

    private participants: Map<Participant.SID, RemoteParticipant>;
    private dominantSpeaker: RemoteParticipant;

    constructor(private readonly renderer: Renderer2) { }

    clear() {
        if (this.participants) {
            this.participants.clear();
        }
    }

    initialize(participants: Map<Participant.SID, RemoteParticipant>) {
        this.participants = participants;
        if (this.participants) {
            this.participants.forEach(participant => this.registerParticipantEvents(participant));
        }
    }

    add(participant: RemoteParticipant) {
        if (this.participants && participant) {
            this.participants.set(participant.sid, participant);
            this.registerParticipantEvents(participant);
        }
    }

    remove(participant: RemoteParticipant) {
        if (this.participants && this.participants.has(participant.sid)) {
            this.participants.delete(participant.sid);
        }
    }

    loudest(participant: RemoteParticipant) {
        this.dominantSpeaker = participant;
    }

    onLeaveRoom() {
        this.leaveRoom.emit(true);
    }

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

    private subscribe(publication: RemoteTrackPublication | any) {
        if (publication && publication.on) {
            publication.on('subscribed', track => this.attachRemoteTrack(track));
            publication.on('unsubscribed', track => this.detachRemoteTrack(track));
        }
    }

    private attachRemoteTrack(track: RemoteTrack) {
        if (this.isAttachable(track)) {
            const element = track.attach();
            this.renderer.data.id = track.sid;
            this.renderer.setStyle(element, 'width', '95%');
            this.renderer.setStyle(element, 'margin-left', '2.5%');
            this.renderer.appendChild(this.listRef.nativeElement, element);
            this.participantsChanged.emit(true);
        }
    }

    private detachRemoteTrack(track: RemoteTrack) {
        if (this.isDetachable(track)) {
            track.detach().forEach(el => el.remove());
            this.participantsChanged.emit(true);
        }
    }

    private isAttachable(track: RemoteTrack): track is RemoteAudioTrack | RemoteVideoTrack {
        return !!track &&
            ((track as RemoteAudioTrack).attach !== undefined ||
            (track as RemoteVideoTrack).attach !== undefined);
    }

    private isDetachable(track: RemoteTrack): track is RemoteAudioTrack | RemoteVideoTrack {
        return !!track &&
            ((track as RemoteAudioTrack).detach !== undefined ||
            (track as RemoteVideoTrack).detach !== undefined);
    }
}

Un ParticipantComponent étend également un EventEmitter et offre son propre ensemble d'événements. Entre la salle, le participant, la publication et la piste, il existe un ensemble complet d'événements à gérer lorsque les participants rejoignent ou quittent une salle. Lorsqu'ils rejoignent une salle, un événement se déclenche et fournit les détails de publication de leurs pistes afin que l'application puisse rendre leur audio et leur vidéo sur le DOM de l'interface utilisateur de chaque client au fur et à mesure que les pistes deviennent disponibles.

Pour implémenter l'interface utilisateur du composant participants, remplacez le contenu du fichier participants/participants.component.html par le balisage HTML suivant :

<div id="participant-list">
    <div id="alone" [ngClass]="{ 'table': isAlone, 'd-none': !isAlone }">
        <p class="text-center text-monospace h3" style="display: table-cell">
            You're the only one in this room. <i class="far fa-frown"></i>
            <br />
            <br />
            As others join, they'll start showing up here...
        </p>
    </div>
    <div [ngClass]="{ 'd-none': isAlone }">
        <nav class="navbar navbar-expand-lg navbar-dark bg-light shadow">
            <ul class="navbar-nav ml-auto">
                <li class="nav-item">
                    <button type="button" class="btn btn-lg leave-room"
                            title="Click to leave this room." (click)="onLeaveRoom()">
                        Leave "{{ activeRoomName }}" Room?
                    </button>
                </li>
            </ul>
        </nav>
        <div #list></div>
    </div>
</div>

Tout comme l'élément CameraComponent, les éléments audio et vidéo associés à un participant sont des cibles de rendu pour l'élément #list du DOM. Cependant, au lieu d'être des pistes locales, il s'agit de pistes distantes publiées par des participants distants.

Implémentation de la gestion des paramètres du périphérique

Quelques composants sont en jeu en ce qui concerne le concept des paramètres. Vous disposez d'un composant camera qui affiche un aperçu de la sélection de caméra des objets DeviceSelectComponent.

Remplacez le contenu du fichier settings/settings.component.ts par le code TypeScript suivant :

import {
    Component,
    OnInit,
    OnDestroy,
    EventEmitter,
    Input,
    Output,
    ViewChild
} from '@angular/core';
import { CameraComponent } from '../camera/camera.component';
import { DeviceSelectComponent } from './device-select.component';
import { DeviceService } from '../services/device.service';
import { debounceTime } from 'rxjs/operators';
import { Subscription } from 'rxjs';

@Component({
    selector: 'app-settings',
    styleUrls: ['./settings.component.css'],
    templateUrl: './settings.component.html'
})
export class SettingsComponent implements OnInit, OnDestroy {
    private devices: MediaDeviceInfo[] = [];
    private subscription: Subscription;
    private videoDeviceId: string;

    get hasAudioInputOptions(): boolean {
        return this.devices && this.devices.filter(d => d.kind === 'audioinput').length > 0;
    }
    get hasAudioOutputOptions(): boolean {
        return this.devices && this.devices.filter(d => d.kind === 'audiooutput').length > 0;
    }
    get hasVideoInputOptions(): boolean {
        return this.devices && this.devices.filter(d => d.kind === 'videoinput').length > 0;
    }

    @ViewChild('camera', { static: false }) camera: CameraComponent;
    @ViewChild('videoSelect', { static: false }) video: DeviceSelectComponent;

    @Input('isPreviewing') isPreviewing: boolean;
    @Output() settingsChanged = new EventEmitter<MediaDeviceInfo>();

    constructor(
        private readonly deviceService: DeviceService) { }

    ngOnInit() {
        this.subscription =
            this.deviceService
                .$devicesUpdated
                .pipe(debounceTime(350))
                .subscribe(async deviceListPromise => {
                    this.devices = await deviceListPromise;
                    this.handleDeviceAvailabilityChanges();
                });
    }

    ngOnDestroy() {
        if (this.subscription) {
            this.subscription.unsubscribe();
        }
    }

    async onSettingsChanged(deviceInfo: MediaDeviceInfo) {
        this.settingsChanged.emit(deviceInfo);
    }

    async showPreviewCamera() {
        this.isPreviewing = true;

        if (!this.camera.videoTrack || this.videoDeviceId !== this.video.selectedId) {
            this.videoDeviceId = this.video.selectedId;
            const videoDevice = this.devices.find(d => d.deviceId === this.video.selectedId);
            await this.camera.initializePreview(videoDevice.deviceId);
        }

        return this.camera.videoTrack;
    }

    hidePreviewCamera() {
        this.isPreviewing = false;
        this.camera.finalizePreview();
        return this.devices.find(d => d.deviceId === this.video.selectedId);
    }

    private handleDeviceAvailabilityChanges() {
        if (this.devices && this.devices.length && this.video && this.video.selectedId) {
            let videoDevice = this.devices.find(d => d.deviceId === this.video.selectedId);
            if (!videoDevice) {
                videoDevice = this.devices.find(d => d.kind === 'videoinput');
                if (videoDevice) {
                    this.video.selectedId = videoDevice.deviceId;
                    this.onSettingsChanged(videoDevice);
                }
            }
        }
    }
}

L'objet SettingsComponent obtient tous les périphériques disponibles et les lie aux objets DeviceSelectComponent qui sont leurs parents. Lorsque les sélections de périphériques d'entrée vidéo changent, l'aperçu des composants de la caméra locale est mis à jour pour refléter ces modifications. L'Observable deviceService.$devicesUpdated se déclenche lorsque la disponibilité des périphériques au niveau du système change. La liste des périphériques disponibles est mise à jour en conséquence.

Pour implémenter l'interface utilisateur pour les paramètres, remplacez le contenu du fichier settings/settings.component.html par le balisage HTML suivant :

<div class="jumbotron">
    <h4 class="display-4"><i class="fas fa-cogs"></i> Settings</h4>
    <form class="form">
        <div class="form-group" *ngIf="hasAudioInputOptions">
            <app-device-select [kind]="'audioinput'"
                               [label]="'Audio Input Source'" [devices]="devices"
                               (settingsChanged)="onSettingsChanged($event)"></app-device-select>
        </div>
        <div class="form-group" *ngIf="hasAudioOutputOptions">
            <app-device-select [kind]="'audiooutput'"
                               [label]="'Audio Output Source'" [devices]="devices"
                               (settingsChanged)="onSettingsChanged($event)"></app-device-select>
        </div>
        <div class="form-group" *ngIf="hasVideoInputOptions">
            <app-device-select [kind]="'videoinput'" #videoSelect
                               [label]="'Video Input Source'" [devices]="devices"
                               (settingsChanged)="onSettingsChanged($event)"></app-device-select>
        </div>
    </form>
    <div [style.display]="isPreviewing ? 'block' : 'none'">
        <app-camera #camera></app-camera>
    </div>
</div>

Si aucune option de périphérique multimédia n'est disponible, l'objet DeviceSelectComponent n'est pas rendu. Lorsqu'une option est disponible, l'utilisateur peut configurer le périphérique souhaité.

Lorsque l'utilisateur modifie le périphérique sélectionné, le composant émet un événement pour tous les auditeurs actifs, ce qui leur permet d'agir sur le périphérique actuellement sélectionné. La liste des périphériques disponibles est mise à jour de manière dynamique lorsque les périphériques sont connectés à l'ordinateur de l'utilisateur ou supprimés de celui-ci.

L'utilisateur voit également un aperçu du périphérique vidéo sélectionné, comme illustré ci-dessous :

Capture d&#x27;écran Composant Settings (Paramètres)

Pour implémenter l'interface utilisateur des paramètres, remplacez le contenu du fichier settings/device-select.component.ts par le code de TypeScript suivant :

import { Component, EventEmitter, Input, Output } from '@angular/core';

class IdGenerator {
    protected static id: number = 0;
    static getNext() {
        return ++ IdGenerator.id;
    }
}

@Component({
    selector: 'app-device-select',
    templateUrl: './device-select.component.html'
})
export class DeviceSelectComponent {
    private localDevices: MediaDeviceInfo[] = [];

    id: string;
    selectedId: string;

    get devices(): MediaDeviceInfo[] {
        return this.localDevices;
    }

    @Input() label: string;
    @Input() kind: MediaDeviceKind;
    @Input() set devices(devices: MediaDeviceInfo[]) {
        this.selectedId = this.find(this.localDevices = devices);
    }

    @Output() settingsChanged = new EventEmitter<MediaDeviceInfo>();

    constructor() {
        this.id = `device-select-${IdGenerator.getNext()}`;
    }

    onSettingsChanged(deviceId: string) {
        this.setAndEmitSelections(this.selectedId = deviceId);
    }

    private find(devices: MediaDeviceInfo[]) {
        if (devices && devices.length > 0) {
            return devices[0].deviceId;
        }

        return null;
    }

    private setAndEmitSelections(deviceId: string) {
        this.settingsChanged.emit(this.devices.find(d => d.deviceId === deviceId));
    }
}

Remplacez le contenu du fichier settings/device-select.component.html par le balisage HTML suivant :

<label for="{{ id }}" class="h5">{{ label }}</label>
<select class="custom-select" id="{{ id }}"
        (change)="onSettingsChanged($event.target.value)">
    <option *ngFor="let device of devices"
            [value]="device.deviceId" [selected]="device.deviceId === selectedId">
        {{ device.label }}
    </option>
</select>

L'objet DeviceSelectComponent est destiné à encapsuler la sélection des périphériques. Plutôt que de gaver le composant de paramètres avec redondance, il n'y a qu'un seul composant qui est réutilisé et paramétré avec les décorateurs @Input et @Output.

Implémentation du composant Home

L'élément HomeComponent agit comme la pièce d'orchestration entre les différents composants et est responsable de la mise en page de l'application.

Pour implémenter l'interface utilisateur de l'accueil, remplacez le contenu du fichier home/home.component.ts par le code de TypeScript suivant :

import { Component, ViewChild, OnInit } from '@angular/core';
import { createLocalAudioTrack, Room, LocalTrack, LocalVideoTrack, LocalAudioTrack, RemoteParticipant } from 'twilio-video';
import { RoomsComponent } from '../rooms/rooms.component';
import { CameraComponent } from '../camera/camera.component';
import { SettingsComponent } from '../settings/settings.component';
import { ParticipantsComponent } from '../participants/participants.component';
import { VideoChatService } from '../services/videochat.service';
import { HubConnection, HubConnectionBuilder, LogLevel } from '@microsoft/signalr';

@Component({
    selector: 'app-home',
    styleUrls: ['./home.component.css'],
    templateUrl: './home.component.html',
})
export class HomeComponent implements OnInit {
    @ViewChild('rooms', { static: false }) rooms: RoomsComponent;
    @ViewChild('camera', { static: false }) camera: CameraComponent;
    @ViewChild('settings', { static: false }) settings: SettingsComponent;
    @ViewChild('participants', { static: false }) participants: ParticipantsComponent;

    activeRoom: Room;

    private notificationHub: HubConnection;

    constructor(
        private readonly videoChatService: VideoChatService) { }

    async ngOnInit() {
        const builder =
            new HubConnectionBuilder()
                .configureLogging(LogLevel.Information)
                .withUrl(`${location.origin}/notificationHub`);

        this.notificationHub = builder.build();
        this.notificationHub.on('RoomsUpdated', async updated => {
            if (updated) {
                await this.rooms.updateRooms();
            }
        });
        await this.notificationHub.start();
    }

    async onSettingsChanged(deviceInfo?: MediaDeviceInfo) {
        await this.camera.initializePreview(deviceInfo.deviceId);
        if (this.settings.isPreviewing) {
            const track = await this.settings.showPreviewCamera();
            if (this.activeRoom) {
                const localParticipant = this.activeRoom.localParticipant;
                localParticipant.videoTracks.forEach(publication => publication.unpublish());
                await localParticipant.publishTrack(track);
            }
        }
    }

    async onLeaveRoom(_: boolean) {
        if (this.activeRoom) {
            this.activeRoom.disconnect();
            this.activeRoom = null;
        }

        const videoDevice = this.settings.hidePreviewCamera();
        await this.camera.initializePreview(videoDevice && videoDevice.deviceId);

        this.participants.clear();
    }

    async onRoomChanged(roomName: string) {
        if (roomName) {
            if (this.activeRoom) {
                this.activeRoom.disconnect();
            }

            this.camera.finalizePreview();

            const tracks = await Promise.all([
                createLocalAudioTrack(),
                this.settings.showPreviewCamera()
            ]);

            this.activeRoom =
                await this.videoChatService
                          .joinOrCreateRoom(roomName, tracks);

            this.participants.initialize(this.activeRoom.participants);
            this.registerRoomEvents();

            this.notificationHub.send('RoomsUpdated', true);
        }
    }

    onParticipantsChanged(_: boolean) {
        this.videoChatService.nudge();
    }

    private registerRoomEvents() {
        this.activeRoom
            .on('disconnected',
                (room: Room) => room.localParticipant.tracks.forEach(publication => this.detachLocalTrack(publication.track)))
            .on('participantConnected',
                (participant: RemoteParticipant) => this.participants.add(participant))
            .on('participantDisconnected',
                (participant: RemoteParticipant) => this.participants.remove(participant))
            .on('dominantSpeakerChanged',
                (dominantSpeaker: RemoteParticipant) => this.participants.loudest(dominantSpeaker));
    }

    private detachLocalTrack(track: LocalTrack) {
        if (this.isDetachable(track)) {
            track.detach().forEach(el => el.remove());
        }
    }

    private isDetachable(track: LocalTrack): track is LocalAudioTrack | LocalVideoTrack {
        return !!track
            && ((track as LocalAudioTrack).detach !== undefined
                || (track as LocalVideoTrack).detach !== undefined);
    }
}

Pour implémenter l'interface utilisateur d'accueil, remplacez le contenu du fichier home/home.component.html par le balisage HTML suivant :

<div class="grid-container">
    <div class="grid-bottom-right">
        <a href="https://twitter.com/davidpine7" target="_blank"><i class="fab fa-twitter"></i> @davidpine7</a>
    </div>
    <div class="grid-left">
        <app-rooms #rooms (roomChanged)="onRoomChanged($event)"
                   [activeRoomName]="!!activeRoom ? activeRoom.name : null"></app-rooms>
    </div>
    <div class="grid-content">
        <app-camera #camera [style.display]="!!activeRoom ? 'none' : 'block'"></app-camera>
        <app-participants #participants
                          (leaveRoom)="onLeaveRoom($event)"
                          (participantsChanged)="onParticipantsChanged($event)"
                          [style.display]="!!activeRoom ? 'block' : 'none'"
                          [activeRoomName]="!!activeRoom ? activeRoom.name : null"></app-participants>
    </div>
    <div class="grid-right">
        <app-settings #settings (settingsChanged)="onSettingsChanged($event)"></app-settings>
    </div>
    <div class="grid-top-left">
        <a href="https://www.twilio.com/video" target="_blank">
            Powered by Twilio
        </a>
    </div>
</div>

Le composant Home fournit la mise en page de l'interface utilisateur du client, il a donc besoin d'un certain style pour organiser et formater les éléments de l'interface utilisateur.

Remplacez le contenu du fichier home/home.component.html par le balisage CSS suivant :

.grid-container {
  display: grid;
  height: 100vh;
  grid-template-columns: 2fr 4fr 2fr;
  grid-template-rows: 1fr 7fr 1fr;
  grid-template-areas: "top-left . top-right" "left content right" "bottom-left . bottom-right";
}

.grid-content {
  grid-area: content;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: rgb(56, 56, 56);
  border-top: solid 6px #F22F46;
  border-bottom: solid 6px #F22F46;
}

.grid-left {
  grid-area: left;
  background: linear-gradient(to left, rgb(56, 56, 56) 0, transparent 100%);
  border-top: solid 6px #F22F46;
  border-bottom: solid 6px #F22F46;
}

.grid-right {
  grid-area: right;
  background: linear-gradient(to right, rgb(56, 56, 56) 0, transparent 100%);
  border-top: solid 6px #F22F46;
  border-bottom: solid 6px #F22F46;
}

.grid-top-left,
.grid-top-right,
.grid-bottom-left,
.grid-bottom-right {
  display: flex;
  justify-content: center;
  align-items: center;
}

.grid-top-left {
  grid-area: top-left;
}
.grid-top-right {
  grid-area: top-right;
}
.grid-bottom-left {
  grid-area: bottom-left;
}
.grid-bottom-right {
  grid-area: bottom-right;
}

Présentation des événements de chat vidéo

L'application client Angular utilise un certain nombre de ressources dans le SDK Twilio Programmable Video. Voici une liste complète de chaque événement associé à une ressource SDK :

Inscription de l'événement

Description

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

Se produit lorsqu'un utilisateur quitte la salle

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

Se produit lorsqu'un nouveau participant rejoint la salle

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

Se produit lorsqu'un participant quitte la salle

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

Se produit lorsqu'une publication de piste est publiée

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

Se produit lorsque la publication de piste est annulée

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

Se produit lorsqu'une piste est souscrite

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

Se produit lorsque la souscription de piste est annulée

Assemblage

Ouf, c'était un sacré projet ! Il est temps de l'essayer.

Exécutez l'application. Si vous exécutez l'application dans Visual Studio 2019 à l'aide d'IIS Express, l'interface utilisateur apparaîtra sur un port affecté de manière aléatoire. Si vous l'exécutez d'une autre manière, accédez à : https://localhost:5001.

Une fois l'application chargée, votre navigateur vous invite à autoriser l'accès à la caméra. Accordez cette autorisation.

Si vous disposez de deux sources vidéo sur votre ordinateur, ouvrez deux navigateurs différents (ou une fenêtre de navigation privée) et sélectionnez des périphériques différents sur chaque navigateur. Les paramètres vous permettent de choisir la source d'entrée vidéo préférée. Dans un navigateur, créez une salle, puis rejoignez-la dans l'autre navigateur.

Lorsqu'une salle est créée, l'aperçu local est déplacé juste sous les paramètres de façon à ce que les participants de la salle distante qui rejoignent voient leur flux vidéo dans la plus grande zone de visualisation.

Si vous n'avez pas deux sources vidéo sur votre ordinateur, restez à l'affut d'un futur post qui vous apprendra à déployer cette application sur Microsoft Azure. Lorsque vous avez déployé l'application sur le cloud, plusieurs utilisateurs peuvent rejoindre des salles de chat vidéo.

Si tout fonctionne correctement, vous avez terminé ce tutoriel. Félicitations ! Vous êtes devenu un as.

Résumé

Ce post vous a montré comment construire une application de chat vidéo entièrement fonctionnelle avec Angular, ASP.NET Core, SignalR, et Twilio Programmable Video. Le SDK Twilio .NET fournit des JWT au code Angular côté client ainsi que des informations sur les salles via l'API Web ASP.NET Core. La SPA Angular côté client intègre le SDK JavaScript Twilio.

Ressources supplémentaires

Un exemple fonctionnel de l'application est disponible sur le domaine Azure de l'auteur :https://ievangelist-videochat.azurewebsites.net/

Le repo GitHub inclut un style amélioré, des sélections continuelles et d'autres fonctionnalités que vous pouvez inclure dans votre application de production.

Pour en savoir plus sur les technologies utilisées dans ce post, consultez les sources suivantes :

Les Observables et Promesses Angular :Observables sur angular.ioUnderstanding, creating and subscribing to observables in Angular (en anglais) et AngularJS Promises – The Definitive Guide (en anglais) sur Medium

Fonctions locales C# :Fonctions locales (Guide de programmation C#) sur docs.microsoft.com

David Pine est un double MVP MicrosoftGoogle Developer Expert, Champion Twilio et conférencier international. Vous pouvez le suivre sur Twitter @davidine7. Pensez à consulter son blog sur https://davidpine.net.

Mise à jour du 09/06/2020 : AJS : Ajout de liens vers les posts précédents.
Mise à jour du 16/06/2020 : AJS : Code dans l'application client synchronisé avec le référentiel, lien vers le post Exécution sur Azure ajouté.