Microsoft.Extensions.Configuration
para que se puedan utilizar como propiedades de un objeto IConfiguration
en la clase Startup
. Las siguientes instrucciones le muestran cómo hacerlo en Windows.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
:dotnet new angular -o VideoChat
dotnet
:dotnet add package Twilio
<ItemGroup>
, como se muestra a continuación, si el comando se completó correctamente. (Los números de versión de su proyecto pueden ser más altos).<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="3.0.0" /> <PackageReference Include="Twilio" Version="5.35.1" /> </ItemGroup>
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
, el cual asigna los valores de las variables del entorno y del archivo appsettings.json a las instancias IOptions<TwilioSettings>
que están disponibles para la inyección de dependencias. En este caso, las variables del entorno son los únicos valores necesarios para la clase TwilioSettings
.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
es un recurso que representa una sala de chat de video.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
, reemplace el contenido del archivo Services/VideoService.cs con el siguiente código:using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using VideoChat.Abstractions; using VideoChat.Models; using VideoChat.Options; using Twilio; using Twilio.Base; using Twilio.Jwt.AccessToken; using Twilio.Rest.Video.V1; using Twilio.Rest.Video.V1.Room; using ParticipantStatus = Twilio.Rest.Video.V1.Room.ParticipantResource.StatusEnum; namespace VideoChat.Services { public class VideoService : IVideoService { readonly TwilioSettings _twilioSettings; public VideoService(Microsoft.Extensions.Options.IOptions<TwilioSettings> twilioOptions) { _twilioSettings = twilioOptions?.Value ?? throw new ArgumentNullException(nameof(twilioOptions)); TwilioClient.Init(_twilioSettings.ApiKey, _twilioSettings.ApiSecret); } public string GetTwilioJwt(string identity) => new Token(_twilioSettings.AccountSid, _twilioSettings.ApiKey, _twilioSettings.ApiSecret, identity ?? Guid.NewGuid().ToString(), grants: new HashSet<IGrant> { new VideoGrant() }).ToJwt(); public async Task<IEnumerable<RoomDetails>> GetAllRoomsAsync() { var rooms = await RoomResource.ReadAsync(); var tasks = rooms.Select( room => GetRoomDetailsAsync( room, ParticipantResource.ReadAsync( room.Sid, ParticipantStatus.Connected))); return await Task.WhenAll(tasks); Static async Task<RoomDetails> GetRoomDetailsAsync( RoomResource room, Task<ResourceSet<ParticipantResource>> participantTask) { var participants = await participantTask; return new RoomDetails { Name = room.UniqueName, MaxParticipants = room.MaxParticipants ?? 0, ParticipantCount = participants.ToList().Count }; } } } }
VideoService
toma una instancia IOptions<TwilioSettings>
e inicializa el TwilioClient
, dada la clave de API proporcionada y el secreto de API correspondiente. Esto se hace de manera estática y permite el uso futuro de diversas funciones basadas en recursos. La implementación del GetTwilioJwt
se utiliza para emitir un nuevo Twilio.JWT.accesstoken.Token
, dado el SID de la cuenta, la clave de API, el secreto de API, la identidad y una nueva instancia de HashSet<IGrant>
con un único objeto VideoGrant
. Antes de regresar, una invocación de la función .ToJwt
convierte la instancia del token en su equivalente de string
.GetAllRoomsAsync
devuelve un listado de objetos de RoomDetails
. Comienza a la espera de la función RoomResource.ReadAsync
, lo que producirá una ResourceSet<RoomResource>
una vez que se haya esperado. Desde esta lista de salas, el código proyecta una serie de Task<RoomDetails>
, donde solicitará el correspondiente ResourceSet<ParticipantResource>
que está conectado actualmente a la sala especificada con el identificador de la sala, Room.UniqueName
.GetAllRoomsService
si no está acostumbrado a codificar después de la declaración return
. C# 8 incluye una función de static local function (función local estática) que permite que las funciones se escriban dentro del alcance del cuerpo del método (“localmente”), incluso después de la declaración de devolución. Las funciones son estáticas para garantizar que las variables no se registren dentro del alcance que abarcan.GetRoomDetailsAsync
para capturar a los participantes conectados de la sala. ¡Esto puede ser un problema de rendimiento! Aunque esto se hace de forma asíncrona y paralela, se debe considerar un cuello de botella potencial y se debe marcar para la refactorización. No es una preocupación en este proyecto de demostración, ya que hay, como máximo, pocas salas. Punto final | Verbo | Tipo | Descripción |
api/video/token | GET | JSON | un objeto con un miembro de token asignado desde el JWT de Twilio |
api/video/salas | GET | JSON | matriz de detalles de la sala: { name, participantCount, maxParticipants } |
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
y un atributo Route
que contiene la plantilla “api/video”
.VideoController
, se inyecta IVideoService
y se asigna a una instancia de campo de 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á un mensaje de forma asíncrona a todos los demás clientes y los notificará cuando se agregue una sala.Startup
y en el método ConfigureServices
.using
las siguientes declaraciones en la parte superior de Startup.cs:using VideoChat.Abstractions; using VideoChat.Hubs; using VideoChat.Options; using VideoChat.Services;
ConfigureServices
, reemplace todo el código existente con el siguiente código:services.AddControllersWithViews(); services.Configure<TwilioSettings>( settings => { settings.AccountSid = Environment.GetEnvironmentVariable("TWILIO_ACCOUNT_SID"); settings.ApiSecret = Environment.GetEnvironmentVariable("TWILIO_API_SECRET"); settings.ApiKey = Environment.GetEnvironmentVariable("TWILIO_API_KEY"); }) .AddTransient<IVideoService, VideoService>() .AddSpaStaticFiles(config => config.RootPath = "ClientApp/dist"); services.AddSignalR();
Configure
, reemplace la llamada app.UseEndpoints
con las siguientes líneas:app.UseEndpoints( endpoints => { endpoints.MapControllerRoute( name: "default", pattern: "{controller}/{action=Index}/{id?}"); endpoints.MapHub<NotificationHub>("/notificationHub"); })
NotificationHub
. Con este punto final, la SPA de Angular que se ejecuta en navegadores de cliente puede enviar mensajes a todos los demás clientes. SignalR ofrece la infraestructura de notificación para este proceso.ng n ClientApp --style css --routing false --minimal true --skipTests true
twilio-video
y @microsoft/signalr
. Sus dependencias de desarrollo incluyen las definiciones de tipo para los @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>
. Este elemento no estándar es utilizado por Angular para hacer que la aplicación de Angular se muestre en la página HTML. El elemento app-root
es el selector del componente AppComponent
.<head>
debajo del elemento <link>
para el ícono de página:<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.11.2/css/all.css" integrity="sha384-UHRtZLI+pbxtHCWp1t77Bi1L4ZtiqrqD80Kn4Z8NTSRyMA2Fd33n5dQ8lWUE00s/" crossorigin="anonymous"> <link rel="stylesheet" href="https://bootswatch.com/4/darkly/bootstrap.min.css">
<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
de @NgModule
de la siguiente manera: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
brindará información sobre los dispositivos multimedia utilizados en la aplicación, incluida su disponibilidad y si el usuario ha otorgado permiso a la app para que los use.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
se utiliza para acceder a los puntos finales del lado del servidor de la API web de ASP.NET Core. Expone la capacidad de obtener la lista de salas y la capacidad de crear una sala nombrada o de unirse a ella.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
(privada). El método getAuthToken
solo se utiliza en la clase VideoChatService
para la invocación de connect
(conectarse) desde el módulo twilio-video
, lo cual se hace de forma asíncrona en el método joinOrCreateRoom
.CameraComponent
también muestra una vista previa de la cámara local. Mediante la representación de las pistas de audio y video creadas de forma local para el modelo de objetos del documento (DOM, por sus siglas en inglés) como elemento <app-camera>
, el SDK de la plataforma JavaScript de video programable Twilio se importa desde twilio-video
, ofrece una API fácil de usar para crear y administrar las pistas locales.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
de Angular se utiliza para obtener una referencia para el elemento HTML #preview
que utilizó en la vista. Con la referencia al elemento, el SDK JavaScript de Twilio puede crear pistas de audio y video locales asociadas con el dispositivo.#preview
. El resultado es una transmisión de video en vivo en la página HTML.RoomsComponent
brinda una interfaz para que los usuarios creen salas cuando ingresan un roomName
a través de un <input type=’text’>
y un elemento <button>
vinculado al método onTryAddRoom
de la clase. La interfaz de usuario tiene el siguiente aspecto:<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
se suscribe al videoChatService.$roomsUpdated
. Cada vez que se crea una sala, RoomsComponent
señalará su creación a través del observable, y el servicio del NotificationHub
estará escuchando. Mediante SignalR, el NotificationHub
hace eco de este mensaje a todos los demás clientes conectados. Este mecanismo permite que el código del lado del servidor brinde funcionalidad web en tiempo real a las app del cliente. En esta aplicación, RoomsComponent
actualizará automáticamente la lista de salas disponibles.RoomsComponent
reemplace el contenido del archivo rooms/rooms.component.ts por el siguiente código TypeScript:import { Component, OnInit, OnDestroy, EventEmitter, Output, Input } from '@angular/core'; import { Subscription } from 'rxjs'; import { tap } from 'rxjs/operators'; import { NamedRoom, VideoChatService } from '../services/videochat.service'; @Component({ selector: 'app-rooms', styleUrls: ['./rooms.component.css'], templateUrl: './rooms.component.html', }) export class RoomsComponent implements OnInit, OnDestroy { @Output() roomChanged = new EventEmitter<string>(); @Input() activeRoomName: string; roomName: string; rooms: NamedRoom[]; private subscription: Subscription; constructor( private readonly videoChatService: VideoChatService) { } async ngOnInit() { await this.updateRooms(); this.subscription = this.videoChatService .$roomsUpdated .pipe(tap(_ => this.updateRooms())) .subscribe(); } ngOnDestroy() { if (this.subscription) { this.subscription.unsubscribe(); } } onTryAddRoom() { if (this.roomName) { this.onAddRoom(this.roomName); } } onAddRoom(roomName: string) { this.roomName = null; this.roomChanged.emit(roomName); } onJoinRoom(roomName: string) { this.roomChanged.emit(roomName); } async updateRooms() { this.rooms = (await this.videoChatService.getAllRooms()) as NamedRoom[]; } }
twilio-video
.RoomsComponent
espera un nombre de sala y una matriz de objetos LocalTrack
. Estas pistas locales provienen de la vista previa de la cámara local, que ofrece tanto una pista de audio como de video. Los objetos de LocalTrack
se publican en las salas a las que se une un usuario para que los demás participantes puedan suscribirse y recibirlas.EventEmitter
. Esto significa que una sala permite el registro de la audiencia de los eventos.ParticipantsComponent
, reemplace el contenido del archivo participants/participants.component.ts con el siguiente código TypeScript:import { Component, ViewChild, ElementRef, Output, Input, EventEmitter, Renderer2 } from '@angular/core'; import { Participant, RemoteTrack, RemoteAudioTrack, RemoteVideoTrack, RemoteParticipant, RemoteTrackPublication } from 'twilio-video'; @Component({ selector: 'app-participants', styleUrls: ['./participants.component.css'], templateUrl: './participants.component.html', }) export class ParticipantsComponent { @ViewChild('list', { static: false }) listRef: ElementRef; @Output('participantsChanged') participantsChanged = new EventEmitter<boolean>(); @Output('leaveRoom') leaveRoom = new EventEmitter<boolean>(); @Input('activeRoomName') activeRoomName: string; get participantCount() { return !!this.participants ? this.participants.size : 0; } get isAlone() { return this.participantCount === 0; } private participants: Map<Participant.SID, RemoteParticipant>; private dominantSpeaker: RemoteParticipant; constructor(private readonly renderer: Renderer2) { } clear() { if (this.participants) { this.participants.clear(); } } initialize(participants: Map<Participant.SID, RemoteParticipant>) { this.participants = participants; if (this.participants) { this.participants.forEach(participant => this.registerParticipantEvents(participant)); } } add(participant: RemoteParticipant) { if (this.participants && participant) { this.participants.set(participant.sid, participant); this.registerParticipantEvents(participant); } } remove(participant: RemoteParticipant) { if (this.participants && this.participants.has(participant.sid)) { this.participants.delete(participant.sid); } } loudest(participant: RemoteParticipant) { this.dominantSpeaker = participant; } onLeaveRoom() { this.leaveRoom.emit(true); } private registerParticipantEvents(participant: RemoteParticipant) { if (participant) { participant.tracks.forEach(publication => this.subscribe(publication)); participant.on('trackPublished', publication => this.subscribe(publication)); participant.on('trackUnpublished', publication => { if (publication && publication.track) { this.detachRemoteTrack(publication.track); } }); } } private subscribe(publication: RemoteTrackPublication | any) { if (publication && publication.on) { publication.on('subscribed', track => this.attachRemoteTrack(track)); publication.on('unsubscribed', track => this.detachRemoteTrack(track)); } } private attachRemoteTrack(track: RemoteTrack) { if (this.isAttachable(track)) { const element = track.attach(); this.renderer.data.id = track.sid; this.renderer.setStyle(element, 'width', '95%'); this.renderer.setStyle(element, 'margin-left', '2.5%'); this.renderer.appendChild(this.listRef.nativeElement, element); this.participantsChanged.emit(true); } } private detachRemoteTrack(track: RemoteTrack) { if (this.isDetachable(track)) { track.detach().forEach(el => el.remove()); this.participantsChanged.emit(true); } } private isAttachable(track: RemoteTrack): track is RemoteAudioTrack | RemoteVideoTrack { return !!track && ((track as RemoteAudioTrack).attach !== undefined || (track as RemoteVideoTrack).attach !== undefined); } private isDetachable(track: RemoteTrack): track is RemoteAudioTrack | RemoteVideoTrack { return !!track && ((track as RemoteAudioTrack).detach !== undefined || (track as RemoteVideoTrack).detach !== undefined); } }
ParticipantComponent
también extiende un EventEmitter
y ofrece su propio conjunto de eventos valiosos. Entre la sala, el participante, la publicación y la pista, hay un conjunto completo de eventos para manejar cuando los participantes se unen a una sala o salen de ella. Cuando se unen, se dispara un evento y brinda detalles de publicación de sus pistas para que la aplicación pueda presentar su audio y video al DOM de interfaz de usuario de cada cliente a medida que las pistas estén disponibles.<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
, los elementos de audio y video asociados con un participante son objetivos de presentación para el elemento #list
del DOM. Pero en lugar de ser pistas locales, estas son pistas remotas publicadas por participantes remotos.camera
debajo de varios 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
obtiene todos los dispositivos disponibles y los une a los objetos DeviceSelectComponent
que controla. A medida que cambian las selecciones de dispositivos de entrada de video, se actualiza la vista previa de los componentes de la cámara local para reflejar esos cambios. Se dispara el deviceService.$devicesUpdated
observable a medida que cambia la disponibilidad del dispositivo en el nivel del sistema. La lista de dispositivos disponibles se actualiza según corresponda.<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
no se muestra. Cuando hay una opción disponible, el usuario puede configurar el dispositivo deseado.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
está diseñado para encapsular la selección de dispositivos. En lugar de expandir el componente de configuración con redundancia, hay un solo componente que se reutiliza y parametriza con decoradores @Input
y @Output.HomeComponent
actúa como la organización entre los diversos componentes y es responsable del diseño de la app.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 | Descripción |
room.on('disconnected', room => { }); | Se produce cuando un usuario abandona la sala |
room.on('participantConnected', participant => { }); | Se produce cuando un nuevo participante se une a la sala |
room.on('participantDisconnected', participant => { }); | Se produce cuando un participante abandona la sala |
participant.on('trackPublished', publication => { }); | Ocurre cuando se hace una publicación de pista |
participant.on('trackUnpublished', publication => { }); | Se produce cuando una publicación de pista no se hace |
publication.on('subscribed', track => { }); | Se produce cuando se suscribe una pista |
publication.on('unsubscribed', track => { }); | Se produce cuando se cancela la suscripción a una pista |