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": "*", "TwilioSettings": { "AccountSid": "Account SID", "ApiSecret": "API Secret", "ApiKey": "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.App" /> <PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="2.2.0" /> <PackageReference Include="Twilio" Version="5.25.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> /// The auth token for your primary Twilio account, hidden on your twilio.com/console dashboard. /// </summary> public string AuthToken { 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 Servicios/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); 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 de diversas funciones basadas en recursos en el futuro. La implementación de 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 a 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 nos dará un ResourceSet<RoomResource>
una vez que se haya esperado. Desde esta lista de salas, proyectaremos una serie de Task<RoomDetails>
, donde solicitaremos 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# 7 incluye una función de local functions (funciones locales) que permite que las funciones se escriban dentro del alcance del cuerpo del método (“a nivel local”), incluso después de la declaración de devolución.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 la llamada services.AddSpaStaticFiles
con el siguiente código:services.Configure<TwilioSettings>(Configuration.GetSection(nameof(TwilioSettings))) .AddTransient<IVideoService, VideoService>() .AddSpaStaticFiles(configuration => configuration.RootPath = "ClientApp/dist/ClientApp"); services.AddSignalR();
Configure
, justo antes de la llamada app.UseMvc
, agregue las siguientes líneas:app.UseSignalR(routes => { routes.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 @aspnet/signalr
. Sus dependencias de desarrollo incluyen las definiciones de tipo para los @aspnet/signalr
.{ "name": "video-chat", "version": "0.0.1", "scripts": { "ng": "ng", "start": "ng serve", "build": "ng build", "lint": "ng lint" }, "private": true, "dependencies": { "@angular/animations": "~7.1.4", "@angular/common": "~7.1.4", "@angular/compiler": "~7.1.4", "@angular/core": "~7.1.4", "@angular/forms": "~7.1.4", "@angular/platform-browser": "~7.1.4", "@angular/platform-browser-dynamic": "~7.1.4", "@angular/router": "~7.1.4", "@aspnet/signalr": "1.1.0", "twilio-video": "2.0.0-beta5", "core-js": "^2.5.4", "rxjs": "~6.3.3", "tslib": "^1.9.0", "zone.js": "~0.8.26" }, "devDependencies": { "@angular-devkit/build-angular": "~0.11.0", "@angular/cli": "~7.1.4", "@angular/compiler-cli": "~7.1.4", "@angular/language-service": "~7.1.4", "@types/node": "~8.9.4", "codelyzer": "~4.5.0", "ts-node": "~7.0.0", "tslint": "~5.11.0", "typescript": "~3.1.6" } }
npm install
npm install
garantiza que se descarguen todas las dependencias de JavaScript requeridas.<app-root>
. Angular utiliza este elemento no estándar 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.6.3/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 que se utilizan 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
.VideoChateService
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 para 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) 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') 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 elemento <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 aplicaciones 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 a 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') 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 los participantes 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') camera: CameraComponent; @ViewChild('videoSelect') 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 de @Input
y @Output
.HomeComponent
actúa como la organización entre los diversos componentes y es responsable del diseño de la aplicación.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 '@aspnet/signalr'; @Component({ selector: 'app-home', styleUrls: ['./home.component.css'], templateUrl: './home.component.html', }) export class HomeComponent implements OnInit { @ViewChild('rooms') rooms: RoomsComponent; @ViewChild('camera') camera: CameraComponent; @ViewChild('settings') settings: SettingsComponent; @ViewChild('participants') 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 participante nuevo se une a la sala |
room.on('participantDisconnected', participant => { }); | Se produce cuando un participante abandona la sala |
participant.on('trackPublished', publication => { }); | Se produce 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 |