Creación de una app de chat de video programable de Twilio con Angular y. ASP.NET Core 3.0

November 20, 2019
Redactado por
David Pine
Colaborador
Las opiniones expresadas por los colaboradores de Twilio son propias.
Revisado por
AJ Saulsberry
Colaborador
Las opiniones expresadas por los colaboradores de Twilio son propias.

Creación de una app de chat de video programable de Twilio con Angular y. ASP.NET Core 3.0

This article is for reference only. We're not onboarding new customers to Programmable Video. Existing customers can continue to use the product until December 5, 2024.


We recommend migrating your application to the API provided by our preferred video partner, Zoom. We've prepared this migration guide to assist you in minimizing any service disruption.

La interacción con el usuario en tiempo real es una excelente manera de mejorar las capacidades de comunicación y colaboración de una aplicación web. El chat de video es una opción obvia para las ventas, la atención al cliente y los sitios educativos, pero ¿es práctico implementarlo? Si está desarrollando con Angular en el front-end y ASP.NET Core para su servidor, el video programable de Twilio le permite agregar de manera eficiente un sólido chat de video a su aplicación.

En esta publicación, se mostrará cómo crear una aplicación de chat de video en funcionamiento mediante el SDK JavaScript de Twilio en su aplicación de una sola página Angular (SPA, por sus siglas en inglés) y el SDK de Twilio para C# y .NET en su código de servidor de ASP.NET Core. Creará las interacciones necesarias para crear y unirse a salas de chat de video, y para publicar y suscribirse a las pistas de audio y video de los participantes.

Si desea ver una integración completa de las API de Twilio en una aplicación .NET Core, entonces eche un vistazo a esta serie de videos de 5 partes. Es independiente de este tutorial publicado en el blog, pero le dará una lista completa de las API de una sola vez.

Hay una versión más reciente de esta publicación

El contenido de esta publicación y su repositorio complementario en GitHub se actualizaron para utilizar tecnologías y estándares más nuevos. Siga el enlace a continuación para llegar a la versión actual de la publicación:

Creación de una app de chat de video con ASP.NET Core 3.1, Angular 9 y Twilio

Esta publicación brinda instrucciones y código para crear una app de chat de video con ASP.NET Core 3.0. Para aprender a crear la misma app con ASP.NET Core 2.2, consulte la publicación: Cree una app de chat de video con ASP.NET Core 2.2, Angular y Twilio.

Requisitos previos

Necesitará las siguientes tecnologías y herramientas para crear el proyecto de chat de video que se describe en esta publicación:

Para aprovechar al máximo esta publicación, debe tener conocimiento de lo siguiente:

  • Angular, incluidas las observaciones y promesas
  • ASP.NET Core, incluida la inyección de dependencias
  • C# 8
  • TypeScript

El código fuente para este proyecto está disponible en GitHub. El código para ASP.NET Core 3.0 se proporciona en la rama maestra. El código para ASP.NET Core 2.2 también se puede encontrar en el mismo repositorio en la rama net2.2.

Comenzar con Twilio Programamble Video

Necesitará una cuenta de prueba de Twilio gratuita y un proyecto de video programable de Twilio para poder crear este proyecto con el SDK de Twilio Video. La configuración tomará solo unos minutos.

Una vez que tenga una cuenta Twilio, vaya a Twilio Console (Consola de Twilio) y realice los siguientes pasos:

  1. En la página de inicio del panel de control, ubique el SID de su cuenta y el token de autenticación y cópielos en un lugar seguro.
  2. Seleccione la sección Programmable Video (Video programable) de la consola.
  3. En Tools (Herramientas) > API Keys (Claves de API), cree una nueva clave de API con un nombre descriptivo de su elección y copie el SID y el secreto de API en un lugar seguro.

Las credenciales que acaba de adquirir son información confidencial del usuario, por lo que es una buena idea no almacenarlas en el código fuente del proyecto. Una manera de mantenerlas seguras y de que sean accesibles en la configuración del proyecto es guardarlas como variables del entorno en su máquina de desarrollo.

ASP.NET Core puede acceder a variables de entorno a través del paquete Microsoft.Extensions.Configuration para que se puedan utilizar como propiedades de un objeto IConfiguration en la clase Startup. Las siguientes instrucciones le muestran cómo hacerlo en Windows.

Ejecute los siguientes comandos en un símbolo del sistema de Windows y sustituya sus credenciales para los marcadores de posición. Para otros sistemas operativos, utilice comandos comparables para crear las mismas variables de entorno.

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

Si lo prefiere, o si su entorno de desarrollo lo requiere, puede colocar estos valores en el archivo appsettings.development.json de la siguiente manera, pero tenga cuidado de no exponer este archivo en un repositorio de código fuente u otra ubicación de fácil acceso.

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

Creación de la aplicación ASP.NET Core

Cree una nueva aplicación web ASP.NET Core llamada “videochat” (chat de video) con .NET Core 3.0 y plantillas de Angular con la interfaz de usuario de Visual Studio 2019 o la siguiente línea de comandos dotnet:

dotnet new angular -o VideoChat

Este comando creará una solución de Visual Studio que contiene un proyecto de ASP.NET Core configurado para utilizar una aplicación de Angular, ClientApp, como front-end. El código del lado del servidor se escribe en C# y tiene dos propósitos principales: en primer lugar, sirve a la aplicación web de Angular, al diseño HTML, a las hojas de estilo en cascada (CSS, por sus siglas en inglés) y al código de JavaScript. En segundo lugar, actúa como una API web. La aplicación del lado del cliente tiene la lógica para presentar cómo se crean las salas de videochat y como se unen los participantes a ellas, y aloja la transmisión de video del participante para chats de video en vivo.

Adición de SDK de Twilio para C# y .NET

La aplicación del servidor de ASP.NET Core utilizará el SDK de Twilio para C# y .NET. Instálelo con el Administrador de paquetes NuGet, la Consola del administrador de paquetes o la siguiente instrucción de línea de comandos dotnet:

dotnet add package Twilio

El archivo VideoChat.csproj debe incluir las referencias del paquete en un nodo <ItemGroup>, como se muestra a continuación, si el comando se completó correctamente. (Los números de versión de su proyecto pueden ser más altos).

<ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.0.0" />
    <PackageReference Include="Twilio" Version="5.35.1" />
</ItemGroup>

Creación de la estructura de carpeta y archivos

Cree las siguientes carpetas y archivos:

/Abstractions (/Abstracciones)

   IVideoService.cs

/Hubs (/Centros)

   NotificationHub.cs

/Models (/Modelos)

   RoomDetails.cs

/Options (/Opciones)

   TwilioSettings.cs

/Services (/Servicios)

   VideoService.cs

Cuando haya terminado, la carpeta Solution Explorer (Navegador de soluciones) y la estructura de archivos deben verse de la siguiente manera:

Estructura del servidor en detalle del navegador de soluciones de Visual Studio

En el directorio /Controllers (/Controladores), cambie el nombre de SampleDataController.cs a VideoController.cs y actualice el nombre de la clase para que coincida con el nuevo nombre del archivo.

Creación de servicios

El código del lado del servidor debe hacer varias cosas clave, una de ellas es brindar un token web JSON (JWT, por sus siglas en inglés) al cliente para que el cliente pueda conectarse a la API de video programable de Twilio. Para hacerlo, se requiere el SID de la cuenta de Twilio, la clave de API y el secreto de API que almacenó como variables del entorno. En ASP.NET Core, es común aprovechar una clase C# con excelente escritura que representará las diferentes configuraciones.

Agregue el siguiente código C# al archivo Options/TwilioSettings.cs debajo de las declaraciones:

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; }
    }
}

Estos valores se configuran en el método Startup.ConfigureServices, el cual asigna los valores de las variables del entorno y del archivo appsettings.json a las instancias IOptions<TwilioSettings> que están disponibles para la inyección de dependencias. En este caso, las variables del entorno son los únicos valores necesarios para la clase TwilioSettings.

Inserte el siguiente código C# en el archivo Models/RoomDetails.cs debajo de las declaraciones:

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 clase RoomDetails es un recurso que representa una sala de chat de video.

Con la inyección de dependencias en mente, cree una abstracción para el servicio de video del lado del servidor como interfaz.

Reemplace el contenido del archivo Abstractions/IVideoService.cs con el siguiente código C#:

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();
    }
}

Esta es una interfaz muy sencilla que expone la capacidad de obtener el JWT de Twilio cuando se le da una identidad. También brinda la capacidad de obtener todas las salas.

Para implementar la interfaz IVideoService, reemplace el contenido del archivo Services/VideoService.cs con el siguiente código:

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
                };
            }
        }
    }
}

El constructor de la clase VideoService toma una instancia IOptions<TwilioSettings> e inicializa el TwilioClient, dada la clave de API proporcionada y el secreto de API correspondiente. Esto se hace de manera estática y permite el uso futuro de diversas funciones basadas en recursos. La implementación del GetTwilioJwt se utiliza para emitir un nuevo Twilio.JWT.accesstoken.Token, dado el SID de la cuenta, la clave de API, el secreto de API, la identidad y una nueva instancia de HashSet<IGrant> con un único objeto VideoGrant. Antes de regresar, una invocación de la función .ToJwt convierte la instancia del token en su equivalente de string.

La función GetAllRoomsAsync devuelve un listado de objetos de RoomDetails. Comienza a la espera de la función RoomResource.ReadAsync, lo que producirá una ResourceSet<RoomResource> una vez que se haya esperado. Desde esta lista de salas, el código proyecta una serie de Task<RoomDetails>, donde solicitará el correspondiente ResourceSet<ParticipantResource> que está conectado actualmente a la sala especificada con el identificador de la sala, Room.UniqueName.

Es posible que note alguna sintaxis desconocida en la función GetAllRoomsService si no está acostumbrado a codificar después de la declaración return. C# 8 incluye una función de static local function (función local estática) que permite que las funciones se escriban dentro del alcance del cuerpo del método (“localmente”), incluso después de la declaración de devolución. Las funciones son estáticas para garantizar que las variables no se registren dentro del alcance que abarcan.

Tenga en cuenta que para cada sala n que existe, se invoca GetRoomDetailsAsync para capturar a los participantes conectados de la sala. ¡Esto puede ser un problema de rendimiento! Aunque esto se hace de forma asíncrona y paralela, se debe considerar un cuello de botella potencial y se debe marcar para la refactorización. No es una preocupación en este proyecto de demostración, ya que hay, como máximo, pocas salas.

Creación del controlador de API

El controlador de video brindará dos puntos finales HTTP GET para que el cliente de Angular los utilice.

Punto final

Verbo

Tipo

Descripción

api/video/token

GET

JSON

un objeto con un miembro de token asignado desde el JWT de Twilio

api/video/salas

GET

JSON

matriz de detalles de la sala:{ name, participantCount, maxParticipants }

Reemplace el contenido del archivo Controllers/VideoController.cs con el siguiente código C#:

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());
    }
}

El controlador está decorado con el atributo ApiController y un atributo Route que contiene la plantilla “api/video”.

En el constructor del VideoController, se inyecta IVideoService y se asigna a una instancia de campo de readonly.

Creación del centro de notificaciones

La aplicación ASP.NET Core no estaría completa sin el uso de SignalR, que “…es una biblioteca de código abierto que simplifica la adición de funcionalidad web en tiempo real a las app. La funcionalidad web en tiempo real permite que el código del lado del servidor envíe contenido de forma instantánea a los clientes”.

Cuando un usuario crea una sala en la aplicación, su código del lado del cliente notificará al servidor y, en última instancia, a otros clientes de la nueva sala. Esto se realiza con un centro de notificaciones de SignalR.

Reemplace el contenido del archivo Hubs/NotificationHub.cs con el siguiente código C#:

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);
    }
}

El NotificationHub enviará un mensaje de forma asíncrona a todos los demás clientes y los notificará cuando se agregue una sala.

Configuración de Startup.cs

Hay algunas cosas que se deben agregar y cambiar en la clase Startup y en el método ConfigureServices.

Agregue el siguiente C# using las siguientes declaraciones en la parte superior de Startup.cs:

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

En el método ConfigureServices, reemplace todo el código existente con el siguiente código:

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();

Esto configura los ajustes de la aplicación que contienen las credenciales de la API de Twilio, asigna la abstracción del servicio de video a su implementación correspondiente, asigna la ruta de raíz para la SPA y agrega SignalR.

En el método Configure, reemplace la llamada app.UseEndpoints con las siguientes líneas:

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

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

Esto asigna el punto final de notificación a la implementación del NotificationHub. Con este punto final, la SPA de Angular que se ejecuta en navegadores de cliente puede enviar mensajes a todos los demás clientes. SignalR ofrece la infraestructura de notificación para este proceso.

Esto concluye la configuración del lado del servidor. Compile el proyecto y asegúrese de que no haya errores.

Creación de la app Angular del lado del cliente

Las plantillas de ASP.NET Core no se actualizan con regularidad, y Angular se actualiza de forma constante. Para crear la app del cliente con el código de Angular más reciente, es mejor comenzar con una plantilla de Angular actual.

Elimine el directorio ClientApp del proyecto de VideoChat (Chat de video).

Abra una ventana de consola (ya sea de PowerShell o la consola de Windows) en el directorio del proyecto de VideoChat (Chat de video) y ejecute el siguiente comando de Angular CLI:

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

Este comando debe crear una nueva carpeta ClientApp en el proyecto de VideoChat (Chat de video) junto con la carpeta y la estructura de archivos básicas para una aplicación de Angular.

La aplicación de Angular tiene varias dependencias, incluidos los paquetes de twilio-video y @microsoft/signalr. Sus dependencias de desarrollo incluyen las definiciones de tipo para los @types/twilio-video.

Reemplace el contenido del package.json por el siguiente código JSON:

{
    "name": "ievangelist-videochat",
    "version": "1.0.0",
    "license": "MIT",
    "scripts": {
        "ng": "ng",
        "start": "ng serve",
        "build": "ng build"
    },
    "private": true,
    "dependencies": {
        "@angular/animations": "8.2.14",
        "@angular/common": "8.2.14",
        "@angular/compiler": "8.2.14",
        "@angular/core": "8.2.14",
        "@angular/forms": "8.2.14",
        "@angular/platform-browser": "8.2.14",
        "@angular/platform-browser-dynamic": "8.2.14",
        "@angular/platform-server": "8.2.14",
        "@angular/router": "8.2.14",
        "@nguniversal/module-map-ngfactory-loader": "7.0.2",
        "@microsoft/signalr": "3.0.1",
        "aspnet-prerendering": "^3.0.1",
        "core-js": "^2.6.1",
        "twilio-video": "2.0.0-beta15",
        "rxjs": "^6.5.3",
        "zone.js": "^0.9.1"
    },
    "devDependencies": {
        "@angular-devkit/build-angular": "^0.800.6",
        "@angular/cli": "8.3.19",
        "@angular/compiler-cli": "8.2.14",
        "@angular/language-service": "8.2.14",
        "@types/node": "~11.10.5",
        "@types/twilio-video": "^2.0.9",
        "codelyzer": "^5.0.1",
        "protractor": "^5.4.2",
        "ts-node": "~7.0.1",
        "tslint": "~5.12.0",
        "typescript": "3.4.5"
    },
    "optionalDependencies": {
        "node-sass": "^4.11.0"
    }
}

Con las actualizaciones del package.json completas, ejecute las siguientes instrucciones de línea de comandos npm en el directorio ClientApp:

npm install

Este comando garantiza que se descarguen e instalen todas las dependencias de JavaScript requeridas.

Abra el archivo /ClientApp/src/index.html y observe el elemento <app-root>. Este elemento no estándar es utilizado por Angular para hacer que la aplicación de Angular se muestre en la página HTML. El elemento app-root es el selector del componente AppComponent.

Agregue el siguiente marcado de HTML al archivo index.html, en el elemento <head> debajo del elemento <link> para el ícono de página:

<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">

Este marcado permite a la aplicación utilizar la versión gratuita de Font Awesome y el tema Boottswatch Darkly.

Continúe con el archivo /src/app/app.component.html y reemplace el contenido con el siguiente marcado de HTML:

<app-home></app-home>

Desde la línea de comandos en el directorio de ClientApp, ejecute los siguientes comandos de Angular CLI para generar los componentes:

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

Luego, ejecute los siguientes comandos de Angular CLI para generar los servicios necesarios:

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

Estos comandos agregan todo el código del texto modelo, lo que le permite centrarse en la implementación de la app. Además, agregan nuevos componentes y servicios, y actualizan el archivo app.module.ts mediante la importación y la declaración de los componentes que crean los comandos.

La estructura de carpetas y archivos debe tener el siguiente aspecto:

Módulo de la app en detalle del navegador de soluciones de Visual Studio

Actualización del módulo de la app de Angular

La aplicación se basa en dos módulos adicionales: uno para implementar formularios y el otro para utilizar HTTP.

Agregue las siguientes dos declaraciones de importación a la parte superior del ClientApp/src/app/app.module.ts:

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

A continuación, agregue estos módulos a la matriz de imports de @NgModule de la siguiente manera:

imports: [
  BrowserModule,
  HttpClientModule,
  FormsModule
],

Adición de polyfills de JavaScript

Un proyecto JavaScript no estaría completo sin polyfills, ¿verdad? Angular no es la excepción. Afortunadamente, la herramienta de Angular ofrece un archivo de polyfill.

Agregue el siguiente JavaScript en la parte inferior del archivo ClientApp/src/polyfill.ts existente:

// 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;

Creación de servicios de Angular

La clase DeviceService brindará información sobre los dispositivos multimedia utilizados en la aplicación, incluida su disponibilidad y si el usuario ha otorgado permiso a la app para que los use.

Reemplace el contenido del archivo services/device.service.ts con el siguiente código TypeScript:

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['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;
    }
}

Este servicio ofrece dispositivos multimedia visibles a los que la audiencia preocupada puede suscribirse. Cuando cambie la información del dispositivo multimedia, como, por ejemplo, desconectar o conectar una cámara web USB, este servicio notificará a todos los usuarios. También intenta esperar a que el usuario conceda permisos a los diversos dispositivos multimedia que consume el SDK de twilio-video.

El VideoChatService se utiliza para acceder a los puntos finales del lado del servidor de la API web de ASP.NET Core. Expone la capacidad de obtener la lista de salas y la capacidad de crear una sala nombrada o de unirse a ella.

Reemplace el contenido del archivo services/videochat.service.ts con el siguiente código TypeScript:

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);
    }
}

Tenga en cuenta que la recuperación del JWT de Twilio se marca como private (privada). El método getAuthToken solo se utiliza en la clase VideoChatService para la invocación de connect (conectarse) desde el módulo twilio-video, lo cual se hace de forma asíncrona en el método joinOrCreateRoom.

Conceptos generales

Ahora que los servicios centrales están implementados, ¿cómo deben interactuar entre sí y cómo deben comportarse? Los usuarios deben poder crear las salas o unirse a ellas. Una sala es un recurso de Twilio y puede tener uno o más participantes. Un participante también es un recurso de Twilio. Del mismo modo, los participantes tienen publicaciones de pistas que brindan acceso a pistas de medios de audio y video. Los participantes y las salas comparten cámaras que ofrecen publicaciones de pistas para pistas de audio y video. La app tiene componentes de Angular para cada uno de estos.

Implementar el componente Cámara

Además de brindar pistas de audio y video para que los participantes de la sala compartan, el CameraComponent también muestra una vista previa de la cámara local. Mediante la representación de las pistas de audio y video creadas de forma local para el modelo de objetos del documento (DOM, por sus siglas en inglés) como elemento <app-camera>, el SDK de la plataforma JavaScript de video programable Twilio se importa desde twilio-video, ofrece una API fácil de usar para crear y administrar las pistas locales.

Reemplace el contenido del archivo camera/camera.component.ts con el siguiente código TypeScript:

import { Component, ElementRef, ViewChild, AfterViewInit, Renderer2 } from '@angular/core';
import { createLocalTracks, LocalTrack, LocalVideoTrack } from 'twilio-video';

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

    get tracks(): LocalTrack[] {
        return this.localTracks;
    }

    isInitializing: boolean = true;

    private videoTrack: LocalVideoTrack;
    private localTracks: LocalTrack[] = [];

    constructor(
        private readonly renderer: Renderer2) { }

    async ngAfterViewInit() {
        if (this.previewElement && this.previewElement.nativeElement) {
            await this.initializeDevice();
        }
    }

    initializePreview(deviceInfo?: MediaDeviceInfo) {
        if (deviceInfo) {
            this.initializeDevice(deviceInfo.kind, deviceInfo.deviceId);
        } else {
            this.initializeDevice();
        }
    }

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

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

            this.finalizePreview();

            this.localTracks = kind && deviceId
                ? await this.initializeTracks(kind, deviceId)
                : await this.initializeTracks();

            this.videoTrack = this.localTracks.find(t => t.kind === 'video') as LocalVideoTrack;
            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;
        }
    }

    private initializeTracks(kind?: MediaDeviceKind, deviceId?: string) {
        if (kind) {
            switch (kind) {
                case 'audioinput':
                    return createLocalTracks({ audio: { deviceId }, video: true });
                case 'videoinput':
                    return createLocalTracks({ audio: true, video: { deviceId } });
            }
        }

        return createLocalTracks({ audio: true, video: true });
    }
}

Reemplace el contenido del archivo camera/camera.component.html con el siguiente marcado de HTML:

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

En el código TypeScript que aparece arriba, el decorador @ViewChild de Angular se utiliza para obtener una referencia para el elemento HTML #preview que utilizó en la vista. Con la referencia al elemento, el SDK JavaScript de Twilio puede crear pistas de audio y video locales asociadas con el dispositivo.

Una vez que se crean las pistas, el código encuentra la pista de video y la agrega al elemento #preview. El resultado es una transmisión de video en vivo en la página HTML.

Implementar el componente Salas

El RoomsComponent brinda una interfaz para que los usuarios creen salas cuando ingresan un roomName a través de un <input type=’text’> y un elemento <button> vinculado al método onTryAddRoom de la clase. La interfaz de usuario tiene el siguiente aspecto:

Lista de salas de chat de video antes de agregar una sala

A medida que los usuarios agregan salas, la lista de salas existentes aparecerá debajo de los controles de creación de salas. El nombre de cada sala existente aparecerá junto con el número de participantes activos y la capacidad de la sala, como se muestra a continuación.

Lista de salas de chat de video después de agregar una sala

Para implementar la interfaz de usuario de salas, reemplace el marcado en el archivo rooms/rooms.component.html con el siguiente marcado de HTML:

<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>

El RoomsComponent se suscribe al videoChatService.$roomsUpdated. Cada vez que se crea una sala, RoomsComponent señalará su creación a través del observable, y el servicio del NotificationHub estará escuchando. Mediante SignalR, el NotificationHub hace eco de este mensaje a todos los demás clientes conectados. Este mecanismo permite que el código del lado del servidor brinde funcionalidad web en tiempo real a las app del cliente. En esta aplicación, RoomsComponent actualizará automáticamente la lista de salas disponibles.

Para implementar la funcionalidad RoomsComponent reemplace el contenido del archivo rooms/rooms.component.ts por el siguiente código TypeScript:

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 profundidad, cuando un usuario selecciona una sala para unirse o crea una sala, se conecta a esa sala a través del SDK de twilio-video.

El RoomsComponent espera un nombre de sala y una matriz de objetos LocalTrack. Estas pistas locales provienen de la vista previa de la cámara local, que ofrece tanto una pista de audio como de video. Los objetos de LocalTrack se publican en las salas a las que se une un usuario para que los demás participantes puedan suscribirse y recibirlas.

Implementación del componente Participantes

¿De qué sirve una sala sin ningún participante? Es simplemente una sala vacía, ¡no es divertida!

Pero las salas tienen algo genial: extienden EventEmitter. Esto significa que una sala permite el registro de la audiencia de los eventos.

Para implementar el ParticipantsComponent, reemplace el contenido del archivo participants/participants.component.ts con el siguiente código TypeScript:

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 también extiende un EventEmitter y ofrece su propio conjunto de eventos valiosos. Entre la sala, el participante, la publicación y la pista, hay un conjunto completo de eventos para manejar cuando los participantes se unen a una sala o salen de ella. Cuando se unen, se dispara un evento y brinda detalles de publicación de sus pistas para que la aplicación pueda presentar su audio y video al DOM de interfaz de usuario de cada cliente a medida que las pistas estén disponibles.

Para implementar la interfaz de usuario para el componente de los participantes, reemplace el contenido del archivo participants/participants.component.html con el siguiente marcado de HTML:

<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>

De forma muy similar al CameraComponent, los elementos de audio y video asociados con un participante son objetivos de presentación para el elemento #list del DOM. Pero en lugar de ser pistas locales, estas son pistas remotas publicadas por participantes remotos.

Implementar la administración de la configuración de los dispositivos

Hay algunos componentes en juego con el concepto de configuración. Tendremos un componente camera debajo de varios objetos DeviceSelectComponents.

Reemplace el contenido del archivo settings/settings.component.ts con el siguiente código TypeScript:

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) {
        if (this.isPreviewing) {
            await this.showPreviewCamera();
        } else {
            this.settingsChanged.emit(deviceInfo);
        }
    }

    async showPreviewCamera() {
        this.isPreviewing = true;

        if (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);
        }
        
        return this.camera.tracks;
    }

    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);
                }
            }
        }
    }
}

El objeto SettingsComponent obtiene todos los dispositivos disponibles y los une a los objetos DeviceSelectComponent que controla. A medida que cambian las selecciones de dispositivos de entrada de video, se actualiza la vista previa de los componentes de la cámara local para reflejar esos cambios. Se dispara el deviceService.$devicesUpdated observable a medida que cambia la disponibilidad del dispositivo en el nivel del sistema. La lista de dispositivos disponibles se actualiza según corresponda.

Para implementar la interfaz de usuario para la configuración, reemplace el contenido del archivo settings/settings.component.html con el siguiente marcado de HTML:

<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 no hay disponible una opción de dispositivo multimedia para seleccionar, el objeto DeviceSelectComponent no se muestra. Cuando hay una opción disponible, el usuario puede configurar el dispositivo deseado.

A medida que el usuario cambia el dispositivo seleccionado, el componente emite un evento a cualquier audiencia activa, lo que le permite realizar acciones en el dispositivo seleccionado actualmente. La lista de dispositivos disponibles se actualiza de forma dinámica a medida que los dispositivos se conectan a la computadora del usuario o se eliminan de ella.

El usuario también ve una vista previa del dispositivo de video seleccionado, como se muestra a continuación:

Lista de salas de chat de video que muestra la sala activa

Para implementar la interfaz de usuario de configuración, reemplace los contenidos del archivo settings/device-select.component.tscon el siguiente código TypeScript:

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));
    }
}

Reemplace el contenido del archivo settings/device-select.component.html con el siguiente marcado de HTML:

<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>

El objeto DeviceSelectComponent está diseñado para encapsular la selección de dispositivos. En lugar de expandir el componente de configuración con redundancia, hay un solo componente que se reutiliza y parametriza con decoradores @Input y @Output.

Implementación del componente Inicio

El HomeComponent actúa como la organización entre los diversos componentes y es responsable del diseño de la app.

Para implementar la interfaz de usuario del inicio, reemplace el contenido del archivo home/home.component.ts con el siguiente código TypeScript:

import { Component, ViewChild, OnInit } from '@angular/core';
import { 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);
    }

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

        this.camera.finalizePreview();
        const videoDevice = this.settings.hidePreviewCamera();
        this.camera.initializePreview(videoDevice);

        this.participants.clear();
    }

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

            this.camera.finalizePreview();
            const tracks = await 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);
    }
}

Para implementar la interfaz de usuario del inicio, reemplace el contenido del archivo home/home.component.html con el siguiente marcado de HTML:

<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>

El componente de inicio proporciona el diseño de la interfaz de usuario del cliente, por lo que necesita algunos arreglos para organizar y dar formato a los elementos de la IU.

Reemplace el contenido del archivo home/home.component.css con el siguiente código CSS:

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

Comprensión de los eventos de chat de video

La app cliente de Angular utiliza una serie de recursos en el SDK de video programable de Twilio. La siguiente es una lista completa de cada evento asociado con un recurso de SDK:

Registro de eventos

Descripción

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

Se produce cuando un usuario abandona la sala

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

Se produce cuando un nuevo participante se une a la sala

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

Se produce cuando un participante abandona la sala

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

Ocurre cuando se hace una publicación de pista

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

Se produce cuando una publicación de pista no se hace

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

Se produce cuando se suscribe una pista

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

Se produce cuando se cancela la suscripción a una pista

Unir todas las piezas

¡Uf! Este fue todo el proyecto. Es hora de probarlo.

Ejecute la aplicación. Si está ejecutando la aplicación en Visual Studio 2019 con IIS Express, la interfaz de usuario aparecerá en un puerto asignado al azar. Si lo ejecuta de otra manera, diríjase a: https://localhost:5001.

Después de que la aplicación se cargue, su navegador le pedirá que permita el acceso a la cámara: concédalo.

Si tiene dos fuentes de video en su computadora, abra dos navegadores diferentes (o una ventana de incógnito) y seleccione diferentes dispositivos en cada navegador. La configuración le permite elegir la fuente de entrada de video preferida. En un navegador, cree una sala y, luego, únase a ella en el otro navegador.

Cuando se crea una sala, la vista previa local se mueve justo debajo de la configuración para que los participantes de la sala remota que se unan a la transmisión de video se muestren en el área de visualización más grande.

Si no tiene dos fuentes de video en su computadora, esté atento a una próxima publicación que le enseñará cómo implementar esta aplicación en Microsoft Azure. Cuando haya implementado la app en la nube, puede hacer que varios usuarios se unan a las salas de chat de video.

Resumen de la creación de una app de chat de video con ASP.NET Core, Angular y Twilio

En esta publicación se mostró cómo crear una aplicación de chat de video completamente funcional con Angular, ASP.NET Core, SignalR y el video programable de Twilio. El SDK .NET de Twilio ofrece JWT al código del lado del cliente de Angular, además de obtener los detalles de la sala a través de la API web de ASP.NET Core. La SPA del lado del cliente de Angular integra el SDK JavaScript de Twilio.

Recursos adicionales

Un ejemplo práctico de la aplicación está disponible en el dominio Azure del autor: https://ievangelist-videochat.azurewebsites.net/

El repositorio complementario de GitHub incluye mejores estilos, selecciones persistentes y otras funciones que desee incluir en su app de producción.

Puede obtener más información sobre las tecnologías que se utilizan en esta publicación de las siguientes fuentes:

Este artículo fue traducido del original "Building a Twilio Programmable Video Chat App with Angular and ASP.NET Core 3.0". Mientras estamos en nuestros procesos de traducción, nos encantaría recibir sus comentarios en help@twilio.com - las contribuciones valiosas pueden generar regalos de Twilio.