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.setx TWILIO_ACCOUNT_SID [Account SID] setx TWILIO_API_SECRET [API Secret] setx TWILIO_API_KEY [SID]
{ "Logging": { "LogLevel": { "Default": "Warning" } }, "AllowedHosts": "*", "TwilioAccountSid":"AccountSID", "TwilioApiSecret":"API Secret", "TwilioApiKey":"SID" }
dotnet
a seguir:dotnet new angular -o VideoChat
dotnet
:dotnet add package Twilio
<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>
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; } } }
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
.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; } } }
RoomDetails
é um objeto que representa uma sala de chat por vídeo.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(); } }
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 }; } } } }
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.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
.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.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. 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 } |
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()); } }
ApiController
e um atributo Route
contendo o modelo "api/video"
.VideoController
, o IVideoService
é injetado e atribuído a uma instância de campo readonly
.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.Startup
e no método ConfigureServices
.using
na parte superior do Startup.cs:using VideoChat.Abstractions; using VideoChat.Hubs; using VideoChat.Options; using VideoChat.Services;
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();
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"); })
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.ng n ClientApp --style css --routing false --minimal true --skipTests true
twilio-video
e @microsoft/signalr
. Suas dependências de desenvolvimento incluem as definições de tipo para @types/twilio-video
.{ "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" } }
npm install
<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
.<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">
<app-home></app-home>
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
ng g s services/videochat --nospec ng g s services/device --nospec
import { FormsModule } from '@angular/forms'; import { HttpClientModule } from '@angular/common/http';
imports
do @NgModule
como exibido:imports: [ BrowserModule, HttpClientModule, FormsModule ],
// 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;
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.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; } }
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.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); } }
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
.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.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 }); } }
<div id="preview" #preview> <div *ngIf="isInitializing">Loading preview... Please wait.</div> </div>
@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.#preview
. O resultado é um feed de vídeo ao vivo renderizado na página HTML.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:<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.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[]; } }
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.EventEmitter
. Isso significa que uma sala permite a inscrição como ouvintes de eventos.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.<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>
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.camera
sob vários objetos DeviceSelectComponents
.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); } } } } }
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.<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>
DeviceSelectComponent
não será renderizado. Quando uma opção está disponível, o usuário pode configurar o dispositivo desejado.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)); } }
<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>
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
.HomeComponent
atua como a peça de orquestração entre os vários componentes e é responsável pelo layout do aplicativo.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); } }
<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>
.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; }
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 |