Crie um app de videoconferência com Twilio Programmable Video e as plataformas Angular e ASP.NET Core 3.0

November 20, 2019
Escrito por
David Pine
Contribuidor
As opiniões expressas pelos colaboradores da Twilio são de sua autoria
Revisado por
AJ Saulsberry
Contribuidor
As opiniões expressas pelos colaboradores da Twilio são de sua autoria

Crie um app de videoconferência com Twilio Programmable Video e as plataformas Angular e ASP.NET Core 3.0

A interação do usuário em tempo real é uma ótima maneira de aprimorar os recursos de comunicação e colaboração de um aplicativo da Web. O chat por vídeo é uma escolha óbvia para sites de vendas, suporte ao cliente e educação. Mas é prático de implementar? Se você estiver desenvolvendo com o Angular no front-end e o ASP.NET Core para o seu servidor, o Twilio Programmable Video permitirá que você adicione um chat por vídeo robusto ao seu aplicativo com eficácia.

Este post mostra como criar um aplicativo de chat por vídeo em execução usando o SDK do JavaScript da Twilio no SPA (single page application, aplicativo de página única) do Angular e o SDK do C# e .NET da Twilio no código do servidor ASP.NET Core. Você desenvolverá as interações necessárias para criação e participação em salas de chat por vídeo e para publicação e inscrição em faixas de áudio e vídeo dos participantes.

Para ver uma integração completa das APIs da Twilio em um aplicativo .NET Core, confira esta série de vídeos em cinco partes gratuita. Ela é separada deste tutorial, mas oferece uma lista completa de muitas APIs de uma só vez.

Há uma versão mais recente deste post

O conteúdo deste post e seu repositório complementar no GitHub foram atualizados para usar tecnologias e padrões mais recentes. Clique no link abaixo para acessar a versão mais recente do post:

Criar um app de chat por vídeo com as plataformas ASP.NET Core 3.1, Angular 9 e Twilio(conteúdo em inglês)

Este post fornece instruções e código para a criação de um app de chat por vídeo com o ASP.NET Core 3.0. Para saber como criar o mesmo app com o ASP.NET Core 2.2, consulte o post: Criar um app de chat por vídeo com as plataformas ASP.NET Core 2.2, Angular e Twilio.

Pré-requisitos

Para criar o projeto de chat por vídeo descrito neste post, serão necessárias as seguintes tecnologias e ferramentas:

Para obter o máximo deste post, é importante conhecer:

  • Angular, incluindo Observables (observáveis) e Promises (promessas)
  • ASP.NET Core, incluindo injeção de dependência
  • C# 8
  • TypeScript

O código-fonte deste projeto está disponível no GitHub. O código para ASP.NET Core 3.0 é fornecido no branch master. O código para ASP.NET Core 2.2 também pode ser encontrado no mesmo repositório do branch net2.2.

Começar a usar o Twilio Programmable Video

Para criar esse projeto com o SDK de vídeo da Twilio, será necessária uma conta de avaliação da Twilio gratuita e um projeto do Twilio Programmable Video. A configuração levará apenas alguns minutos.

Assim que você tiver uma conta da Twilio, acesse o console da Twilio e siga estas etapas:

  1. Na página inicial do dashboard, localize o Account SID (SID da conta) e o Auth Token (token de autenticação) e copie-os em um local seguro.
  2. Selecione a seção Programmable Video do console.
  3. Em Tools (Ferramentas) > API Keys (Chaves de API), crie uma nova chave de API com um nome amigável de sua escolha e copie o SID e o API Secret (segredo de API) em um local seguro.

As credenciais recém adquiridas são segredos de usuário, por isso é melhor não armazená-las no código-fonte do projeto. Uma maneira de mantê-las seguras e torná-las acessíveis na configuração do projeto é armazená-las como variáveis de ambiente na máquina de desenvolvimento.

ASP.NET Core pode acessar variáveis de ambiente por meio do pacote Microsoft.Extensions.Configuration para que possam ser usadas como propriedades de um objeto IConfiguration na classe Startup. Nas instruções a seguir, veja como fazer isso no Windows.

Execute os comandos abaixo em um prompt de comando do Windows, substituindo suas credenciais pelos espaços reservados. Em outros sistemas operacionais, use comandos compatíveis para criar as mesmas variáveis de ambiente.

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

Caso você prefira, ou se o seu ambiente de desenvolvimento exigir, é possível colocar esses valores no arquivo appsettings.development.json, conforme indicado. Cuidado para não expor esse arquivo em um repositório de código-fonte ou em outro local de fácil acesso.

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

Criar o aplicativo ASP.NET Core

Com o modelo Angular e o NET Core 3.0, crie um aplicativo da Web ASP.NET chamado "VideoChat", usando a interface do usuário do Visual Studio 2019 ou a linha de comando dotnet a seguir:

dotnet new angular -o VideoChat

Este comando criará uma solução Visual Studio contendo um projeto ASP.NET Core configurado para usar um aplicativo Angular, ClientApp, como front-end. O código do lado do servidor é escrito em C# e tem duas finalidades principais: em primeiro lugar, ele serve o aplicativo da Web Angular, o layout HTML, o CSS e o código JavaScript. Em segundo lugar, ele funciona como uma API da Web. O aplicativo do lado do cliente tem a lógica para apresentar como as salas de chat por vídeo são criadas e acessadas, além de hospedar a transmissão de vídeo do participante para chats por vídeo ao vivo.

Adicionar o SDK da Twilio para C# e .NET

O aplicativo de servidor ASP.NET Core usará o SDK da Twilio para C# e .NET. Instale-o com o gerenciador de pacotes NuGet, o console do gerenciador de pacotes ou com a seguinte instrução de linha de comando dotnet:

dotnet add package Twilio

Ao concluir o comando com sucesso, o arquivo VideoChat.csproj deverá incluir as referências de pacote em um node (nó) <ItemGroup> como exibido abaixo. (Os números de versão no projeto podem ser posteriores).

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

Criar a pasta e a estrutura de arquivos

Crie as seguintes pastas e arquivos:

/Abstractions

   IVideoService.cs

/Hubs

   NotificationHub.cs

/Models

   RoomDetails.cs

/Options

   TwilioSettings.cs

/Services

   VideoService.cs

Quando terminar, a pasta Solution Explorer e a estrutura de arquivos devem ter esta aparência:

Estrutura do servidor do Visual Studio Solution Explorer em detalhes

No diretório /Controllers , renomeie SampleDataController.cs como VideoController.cs e atualize o nome da classe para corresponder ao novo nome do arquivo.

Criar serviços

O código do lado do servidor precisa fazer várias coisas importantes, uma delas é fornecer um JSON Web Token (JWT) para que o cliente se conecte à API do Twilio Programmable Video. Para isso, são necessários o Account SID (SID da conta) Twilio, a API Key (chave de API) e o API Secret (segredo de API) armazenados como variáveis de ambiente. No ASP.NET Core, é comum aproveitar uma classe C# fortemente tipada para representar as várias configurações.

Adicione o seguinte código C# ao arquivo Options/TwilioSettings.cs abaixo das declarações:

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

Essas configurações são definidas no método Startup.ConfigureServices, que mapeia os valores das variáveis de ambiente e do arquivo appsettings.json para as instâncias IOptions<TwilioSettings> disponíveis para injeção de dependência. Nesse caso, as variáveis de ambiente são os únicos valores necessários para a classe TwilioSettings.

No arquivo Models/RoomDetails.cs, abaixo das declarações, insira o código C# a seguir:

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

A classe RoomDetails é um objeto que representa uma sala de chat por vídeo.

Tendo em mente a injeção de dependência, crie uma abstração para o serviço de vídeo do lado do servidor como uma interface.

Substitua os conteúdos do arquivo Abstractions/IVideoService.cs pelo seguinte código no 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();
    }
}

Essa é uma interface muito simples que revela a capacidade de obtenção do JWT da Twilio ao receber uma identidade, além de oferecer a capacidade de obtenção de todas as salas.

Para implementar a interface IVideoService, substitua os conteúdos do arquivo Services/VideoService.cs pelo seguinte 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
                };
            }
        }
    }
}

O construtor de classe VideoService usa uma instância IOptions<TwilioSettings> e inicializa o TwilioClient, considerando a API Key (chave de API) fornecida e o API Secret (segredo de API) correspondente. Isso é feito de maneira estática e permite o uso posterior de várias funções baseadas em recursos. A implementação do GetTwilioJwt é usada para emitir um novo Twilio.Jwt.AccessToken.Token, considerando o Account SID (SID da conta), a API Key (chave da API), o API Secret (segredo da API), a identidade e uma nova instância HashSet<IGrant> com um único objeto VideoGrant. Antes de retornar, uma chamada da função .ToJwt converte a instância do token em sua string equivalente.

A função GetAllRoomAsync retorna uma lista de objetos RoomDetails. Ele começa aguardando a função RoomResource.ReadAsync, que produzirá um ResourceSet<RoomResource>. Nesta lista de salas, o código projeta uma série de Task<RoomDetails>, onde solicitará o ResourceSet<ParticipantResource> correspondente atualmente conectado à sala especificada com o identificador da sala room.UniqueName.

Talvez você perceba alguma sintaxe desconhecida na função GetAllRoomsService se não estiver acostumado a codificar após a declaração return. O C# 8 inclui um recurso de função local estática que permite que as funções sejam escritas dentro do escopo do corpo do método ("localmente"), mesmo após a declaração de retorno. Elas são estáticas para garantir que as variáveis não sejam capturadas dentro do escopo de fechamento.

Observe que para cada sala n existente, GetRoomDetailsAsync é chamado para buscar os participantes conectados da sala. Isso pode ser preocupante para o desempenho! Embora isso seja feito de forma assíncrona e paralela, deve ser considerado um possível gargalo e marcado para refatoração. Não é uma preocupação neste projeto de demonstração, pois existem apenas algumas salas.

Criar o controlador de API

O controller (controlador) de vídeo fornecerá dois endpoints HTTP GET para o cliente Angular usar.

Endpoint

Verbo

Tipo

Descrição

api/video/token

GET

JSON

um objeto com um membro token atribuído no JWT da Twilio

api/video/rooms

GET

JSON

matriz de detalhes da sala: { name, participantCount, maxParticipants }

Substitua o conteúdo do arquivo Controllers/VideoController.cs pelo seguinte 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());
    }
}

O controller (controlador) é decorado com o atributo ApiController e um atributo Route contendo o modelo "api/video".

No constructor (construtor) VideoController, o IVideoService é injetado e atribuído a uma instância de campo readonly.

Criar o hub de notificação

O aplicativo ASP.NET Core estaria incompleto sem o uso do SignalR, que "... é uma biblioteca de open source que simplifica a adição de funcionalidades da Web a aplicativos em tempo real. A funcionalidade da Web em tempo real permite que o código do lado do servidor envie conteúdo aos clientes instantaneamente".

Quando um usuário cria uma sala no aplicativo, o código do lado do cliente notifica o servidor e, por fim, outros clientes da nova sala. Isso é feito com um hub de notificação SignalR.

Substitua o conteúdo do arquivo Hubs/NotificationHub.cs pelo seguinte 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);
    }
}

NotificationHub enviará uma mensagem de forma assíncrona a todos os outros clientes notificando-os quando uma sala for adicionada.

Configure o Startup.cs

É necessário adicionar e alterar algumas coisas na classe Startup e no método ConfigureServices.

Adicione as seguintes declarações C# using na parte superior do Startup.cs:

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

No método ConfigureServices, substitua todo o código existente pelo seguinte 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();

Isso define as configurações do aplicativo que contêm as credenciais da API da Twilio, mapeia a  abstração do serviço de vídeo para sua implementação correspondente, atribui o caminho raiz do SPA e adiciona o SignalR.

No método Configure, substitua a chamada app.UseEndpoints pelas seguintes linhas:

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

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

Isso mapeia o endpoint de notificação para a implementação do NotificationHub. Usando esse endpoint, o SPA do Angular em execução nos navegadores do cliente pode enviar mensagens para todos os outros clientes. O SignalR fornece a infraestrutura de notificação para esse processo.

Isso conclui a configuração do lado do servidor. Compile o projeto e verifique se não há erros.

Criar o aplicativo Angular do lado do cliente

Os modelos do ASP.NET Core não são atualizados regularmente; o Angular é atualizado constantemente. Para criar o app do cliente com o código Angular mais recente, o melhor é começar com um modelo Angular atual.

Exclua o diretório ClientApp do projeto VideoChat.

Abra uma janela do console (console do Windows ou PowerShell) no diretório do projeto VideoChat e execute o seguinte comando da CLI do Angular:

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

Esse comando deve criar uma pasta ClientApp no projeto VideoChat, com a estrutura básica de pastas e arquivos de um aplicativo Angular.

O aplicativo Angular tem várias dependências, incluindo os pacotes twilio-video e @microsoft/signalr. Suas dependências de desenvolvimento incluem as definições de tipo para @types/twilio-video.

Substitua o conteúdo do package.json pelo seguinte 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"
    }
}

Com as atualizações do package.json concluídas, execute a seguinte instrução de linha de comando do npm no diretório ClientApp:

npm install

Esse comando garante o download e a instalação de todas as dependências de JavaScript necessárias.

Abra o arquivo /ClientApp/src/index.html e observe o elemento <app-root>. Esse elemento não padrão é usado pelo Angular para renderizar o aplicativo Angular na página HTML. O elemento app-root é o seletor do componente AppComponent.

Adicione a seguinte marcação HTML ao arquivo index.html, no elemento <head> abaixo do elemento <link> para o 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">

Essa marcação permite que o aplicativo use a versão gratuita do Font Awesome e o tema Bootswatch Darkly.

Continue no arquivo /src/app/app.component.html e substitua o conteúdo pela seguinte marcação HTML:

<app-home></app-home>

Na linha de comando no diretório ClientApp, execute os seguintes comandos da CLI do Angular para gerar os 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

Em seguida, execute os seguintes comandos da CLI do Angular para gerar os serviços necessários:

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

Esses comandos adicionam todo o código padrão, permitindo que você se concentre na implementação do app. Eles adicionam novos componentes e serviços e atualizam o arquivo app.module.ts ao importar e declarar os componentes que os comandos criam.

A estrutura de pastas e arquivos deve ter a seguinte aparência:

Módulo do aplicativo do Visual Studio Solution Explorer em detalhes

Atualizar o módulo do app Angular

O aplicativo depende de dois módulos adicionais: um para implementar formulários e o outro para usar HTTP.

Adicione as duas instruções de importação a seguir à parte superior do ClientApp/src/app/app.module.ts:

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

Em seguida, adicione esses módulos a matriz imports do @NgModule como exibido:

imports: [
  BrowserModule,
  HttpClientModule,
  FormsModule
],

Adicione polyfills JavaScript

Um projeto JavaScript estaria incompleto sem polyfills, certo? O mesmo ocorre com o Angular. Felizmente, as ferramentas do Angular dispõem de um arquivo polyfill.

Adicione o seguinte JavaScript à parte inferior do arquivo 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;

Criar serviços Angular

A classe DeviceService fornecerá informações sobre os dispositivos de mídia usados no aplicativo, incluindo sua disponibilidade e se o usuário concedeu permissão ao app para usá-los.

Substitua o conteúdo do arquivo services/device.service.ts pelo seguinte 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 serviço fornece dispositivos de mídia observáveis aos quais os ouvintes interessados podem se inscrever. Quando as informações do dispositivo de mídia são alteradas, como desconectar ou conectar uma câmera da Web USB, esse serviço notificará todos os ouvintes. Ele também tenta aguardar que o usuário conceda permissões aos vários dispositivos de mídia consumidos pelo SDK twilio-video.

VideoChatService é usado para acessar os endpoints da API da Web do ASP.NET Core no lado do servidor. Ele expõe a capacidade de obtenção da lista de salas e de criação ou participação em uma sala nomeada.

Substitua o conteúdo do arquivo services/videochat.service.ts pelo seguinte 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);
    }
}

Observe que a recuperação do Twilio JWT é marcada como private. O método getAuthToken é usado somente na classe VideoChatService para a chamada de connect do módulo twilio-video, que é feito de forma assíncrona no método joinOrCreateRoom.

Conceitos gerais

Agora que os principais serviços estão implementados, como eles devem interagir uns com os outros e como eles devem se comportar? Os usuários precisam ser capazes de criar ou ingressar em salas. Uma sala é um recurso da Twilio e pode ter um ou mais participantes. Um participante também é um recurso da Twilio. Da mesma forma, os participantes têm publicações de faixas que fornecem acesso a faixas de mídia de vídeo e áudio. Os participantes e as salas compartilham câmeras que fornecem publicações de faixas de áudio e vídeo. O app tem componentes do Angular para cada um deles.

Implementar o componente Camera

Além de fornecer faixas de áudio e vídeo para os participantes da sala compartilharem, o CameraComponent também permite visualizar a câmera local. Ao renderizar faixas de áudio e vídeo criadas localmente para o DOM como o elemento <app-camera>. O SDK da plataforma de JavaScript do Twilio Programmable Video, importado do twilio-video, fornece uma API fácil de usar para criar e gerenciar as faixas locais.

Substitua o conteúdo do arquivo camera/camera.component.ts pelo seguinte 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 });
    }
}

Substitua o conteúdo do arquivo camera/camera.component.html pela seguinte marcação HTML:

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

No código TypeScript acima, o decorador Angular @ViewChild é usado para obter uma referência ao elemento HTML #preview usado na visualização. Com a referência ao elemento, o SDK JavaScript da Twilio pode criar faixas de vídeo e áudio locais associadas ao dispositivo.

Depois que as faixas são criadas, o código encontra a faixa de vídeo e a anexa ao elemento #preview. O resultado é um feed de vídeo ao vivo renderizado na página HTML.

Implementar o componente Rooms

RoomsComponent fornece uma interface para que usuários criem salas inserindo um roomName por meio de um elemento <input type=’text’> e um elemento <button> vinculados ao método onTryAddRoom da classe. A interface do usuário tem a seguinte aparência:

Lista de chats por vídeo do Room antes da adição de uma sala

À medida que os usuários adicionam salas, a lista de salas existentes será exibida abaixo dos controles de criação de sala. O nome de cada sala existente serão exibidos junto com o número de participantes ativos e a capacidade da sala, como no exemplo mostrado abaixo.

Lista de chats por vídeo do Room após a adição de uma sala

Para implementar a interface de usuário das salas, substitua a marcação no arquivo rooms/rooms.component.html pela seguinte marcação 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>

RoomsComponent inscreve-se no videoChatService.$roomsUpdated observável. Sempre que uma sala for criada o RoomsComponent sinalizará sua criação através do observável e o serviço NotificationHub ouvirá. Usando o SignalR, o NotificationHub transmite essa mensagem para todos os outros clientes conectados. Esse mecanismo permite que o código do lado do servidor forneça funcionalidade da Web em tempo real aos apps do cliente. Neste aplicativo, o RoomsComponent atualizará automaticamente a lista de salas disponíveis.

Para implementar a funcionalidade RoomsComponent, substitua o conteúdo do arquivo rooms/rooms.component.ts pelo seguinte 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[];
    }
}

Na realidade, quando um usuário seleciona uma sala para ingressar ou cria uma sala, ele se conecta a ela por meio do SDK twilio-video.

RoomsComponent espera um nome de sala e uma matriz de objetos LocalTrack. Essas faixas locais vêm da visualização da câmera local, que fornece uma faixa de áudio e vídeo. Os objetos LocalTrack são publicados em salas nas quais um usuário participa para que outros participantes possam participar e recebê-los.

Implementar o componente Participants

O que há de bom em uma sala sem participantes? É apenas uma sala vazia, não tem graça!

Mas as salas têm algo muito legal: elas ampliam EventEmitter. Isso significa que uma sala permite a inscrição como ouvintes de eventos.

Para implementar o ParticipantsComponent, substitua o conteúdo do arquivo participants/participants.component.ts pelo seguinte 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);
    }
}

ParticipantComponent também estende um EventEmitter e oferece seu próprio conjunto de eventos relevantes. Entre a sala, o participante, a publicação e a faixa, há todo um conjunto de eventos para lidar quando os participantes entram ou saem de uma sala. Quando eles entram, um evento é acionado e fornece detalhes da publicação de suas faixas para que o aplicativo possa renderizar seu áudio e vídeo para a interface do usuário do DOM de cada cliente à medida que as faixas se tornam disponíveis.

Para implementar a interface do usuário para o componente participantes, substitua o conteúdo do arquivo participants/participants.component.html pela seguinte marcação 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>

Assim como o CameraComponent, os elementos de áudio e vídeo associados a um participante são alvos de renderização para o elemento #list do DOM. Mas em vez de serem faixas locais, estas são faixas remotas publicadas por participantes remotos.

Implementar o gerenciamento de configurações do dispositivo

Há alguns componentes em jogo com o conceito de configurações. Teremos um componente camera sob vários objetos DeviceSelectComponents.

Substitua o conteúdo do arquivo settings/settings.component.ts pelo seguinte 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);
                }
            }
        }
    }
}

O objeto SettingsComponent obtém todos os dispositivos disponíveis e os vincula aos objetos DeviceSelectComponent dos quais ele é o pai. Conforme as seleções do dispositivo de entrada de vídeo mudam, a visualização do componente da câmera local é atualizada para refletir essas alterações. O observável deviceService.$devicesUpdated é acionado à medida em que há alterações na disponibilidade do dispositivo no nível do sistema. A lista de dispositivos disponíveis é atualizada de acordo.

Para implementar a interface do usuário para configurações, substitua o conteúdo do arquivo settings/settings.component.html pela seguinte marcação 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>

Caso a opção do dispositivo de mídia não esteja disponível para seleção, o objeto DeviceSelectComponent não será renderizado. Quando uma opção está disponível, o usuário pode configurar o dispositivo desejado.

Conforme o usuário altera o dispositivo selecionado, o componente emite um evento para os ouvintes ativos, permitindo que eles executem uma ação neste dispositivo. A lista de dispositivos disponíveis é atualizada dinamicamente à medida que os dispositivos são conectados ou removidos do computador do usuário.

O usuário também visualiza o dispositivo de vídeo selecionado, como exibido abaixo:

Lista de chats por vídeo do Room com salas ativas

Para implementar a interface de usuário de configurações, substitua os conteúdos do arquivo settings/device-select.component.ts pelo seguinte 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));
    }
}

Substitua o conteúdo do arquivo settings/device-select.component.html file pela seguinte marcação 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>

O objeto DeviceSelectComponent destina-se a encapsular a seleção de dispositivos. Em vez de sobrecarregar o componente de configurações com redundância, um único componente é reutilizado e parametrizado com os decoradores @Input e @Output.

Implementar o componente Home

HomeComponent atua como a peça de orquestração entre os vários componentes e é responsável pelo layout do aplicativo.

Para implementar a interface de usuário inicial, substitua o conteúdo do arquivo home/home.component.ts pelo seguinte código de 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 a interface do usuário inicial, substitua o conteúdo do arquivo home/home.component.html pela seguinte marcação 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>

O componente home fornece o layout para a interface do usuário do cliente, portanto, ele precisa de um estilo para organizar e formatar os elementos da interface do usuário.

Substitua o conteúdo do arquivo home/home.component.css pelo seguinte 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;
}

Entenda os eventos de chat por vídeo

O app do cliente Angular utiliza vários recursos no SDK do Twilio Programmable Video. Veja abaixo uma lista abrangente de cada evento associado a um recurso do SDK:

Registro de eventos

Descrição

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

Ocorre quando um usuário sai da sala

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

Ocorre quando um novo participante entra na sala

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

Ocorre quando um participante sai da sala

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

Ocorre quando uma faixa é publicada

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

Ocorre quando uma faixa não é publicada

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

Ocorre na inscrição de uma faixa

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

Ocorre quando a inscrição de uma faixa é cancelada

Coloque tudo em funcionamento

UFA, esse foi um projeto e tanto! É hora de experimentar.

Execute o aplicativo. Caso execute o aplicativo no Visual Studio 2019 usando o IIS Express, a interface do usuário será exibida em uma porta atribuída aleatoriamente. Se você executar de outra forma, acesse: https://localhost:5001.

Depois que o aplicativo carregar, o navegador solicitará permissão para acessar a câmera. Conceda o acesso.

Caso possua duas fontes de vídeo no computador, abra dois navegadores diferentes, ou uma janela anônima e selecione dispositivos diferentes em cada navegador. As configurações permitem que você escolha a fonte de entrada de vídeo preferida. Em um navegador, crie uma sala e, em seguida, entre na sala pelo outro navegador.

Ao criar uma sala, a visualização local move-se para baixo das configurações, de forma que a transmissão de vídeo dos novos participantes seja renderizada na área de visualização maior.

Se você não tiver duas fontes de vídeo no computador, o próximo post o ensinará a implantar esse aplicativo no Microsoft Azure. Uma vez que o app estiver implantado na nuvem, será possível que vários usuários ingressem em salas de chat por vídeo.

Resumo de como criar um app de chat por vídeo com ASP.NET Core, Angular e Twilio

Neste post, vimos como criar um aplicativo de chat por vídeo em total funcionamento com Angular, ASP.NET Core, SignalR e o Twilio Programmable Video. O SDK .NET da Twilio fornece JWTs ao código Angular do lado do cliente, além de obter os detalhes da sala por meio da API da Web ASP.NET Core. O Angular SPA do lado do cliente integra o SDK JavaScript da Twilio.

Recursos adicionais

Um exemplo funcional do aplicativo está disponível no domínio Azure do autor https://ievangelist-videochat.azurewebsites.net/

repositório complementar do GitHub possui melhor estilo, seleções persistentes e outros recursos que você pode querer incluir em seu app de produção.

Saiba mais sobre as tecnologias usadas neste post a partir das seguintes fontes:

Este artigo foi traduzido do original "Building a Twilio Programmable Video Chat App with Angular and ASP.NET Core 3.0". Enquanto melhoramos nossos processos de tradução, adoraríamos receber seus comentários em help@twilio.com - contribuições valiosas podem render brindes da Twilio.