Integrating Agora Web SDK with Angular 17: A Step-by-Step Guide

Hermes
Agora.io
Published in
12 min readMay 16, 2024

In today’s fast-paced digital world, real-time communication is a must-have feature in most web applications. Agora’s Video SDK simplifies building scalable real-time video applications.

This guide will walk through the process of implementing the Agora Web SDK with Angular 17, enabling seamless integration of live audio and video streaming into web apps.

Following the initial setup, we’ll dive into the details of implementing the Agora RTC Web SDK as an Angular service, focusing on creating and managing Angular components for handling local and remote video streams. Each step is designed to provide you with practical, hands-on understanding to ensure you can tie all the components together effectively. By the end of this guide, you’ll be ready to transform your web applications into a fully functional real-time communication platform.

Let’s get started.

Prerequisites

Project Setup

Create a new project (replace agora-angular-demo with your project name)

ng new agora-angular-demo

Navigate to the project directory using

cd agora-angular-demo

Install the Agora Video SDK for Web

npm i agora-rtc-sdk-ng

Implementing the Agora Video SDK

The Agora SDK has three core elements, the Agora RTC Engine, the Local user, and the remote users. To integrate these elements with Angular we’ll set up the Agora RTC Engine as a service with the local user and remote users as components.

Agora Service

To create the Agora Service, use the command

ng generate service agora

Configure the Agora Service Open the agora.service.ts file and import the Agora Video Web SDK. We’ll start by setting up the properties for the Agora Service:

  • The client, represents the Agora RTC Client which is used for joining and leaving channels.
  • The Agora AppId is loaded from the environment variable (get this from the Agora Console)
  • Set up an observable channelJoined$ that other components can subscribe to for the join and leave events.

The Agora Service exposes 3 core functions, joinChannel and leaveChannel for joining and leaving Agora channels, along with a function to setupLocalTracks which initializes the mic and camera streams.

/* agora.service.ts */
import { Injectable } from '@angular/core';
import AgoraRTC, { ILocalTrack, IAgoraRTCClient } from 'agora-rtc-sdk-ng';
import { environment } from '../environments/environments';
import { BehaviorSubject } from 'rxjs';

@Injectable({
providedIn: 'root'
})
export class AgoraService {
private client: IAgoraRTCClient;
private appId = environment.AgoraAppId

private channelJoinedSource = new BehaviorSubject<boolean>(false);
channelJoined$ = this.channelJoinedSource.asObservable();

constructor() {
if(this.appId == '')
console.error('APPID REQUIRED -- Open AgoraService.ts and update appId ')
this.client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp9'})
}

async joinChannel(channelName: string, token: string | null, uid: string | null) {
await this.client.join(this.appId, channelName, token, uid)
this.channelJoinedSource.next(true)
}

async leaveChannel() {
await this.client.leave()
this.channelJoinedSource.next(false)
}

setupLocalTracks(): Promise<ILocalTrack[]> {
return AgoraRTC.createMicrophoneAndCameraTracks();
}

getClient() {
return this.client
}
}

Local and Remote User Components

Now that we have our Agora Service setup we need to create components for our local and remote users. We’ll need to create a few components to compartmentalize the elements:

  • The LocalUser component will be made up of two components, the local-stream as the main component that interacts with the Agora Service and a nested component media-controls to hold the buttons that handle passing the user actions (mute mic/video or leave channel) back to the local-stream.
  • Remote users will be similar in structure, with a main component (remote-stream) as the container for all remote user div elements and a separate component remote-user for the individual remote users. These components will be dynamically added/removed from the container every time a remote user publishes/unpublishes their audio/video streams.

Local User Components

To create the local user components, use the commands

ng generate component local-stream
ng generate component media-controls

This will create the following directories see the following directories and their respective files (.html, .css, .ts):

Starting with the HMTL, open the local-stream.component.html, and replace the placeholder tags with:

<div id="local-video-container">
<app-media-controls></app-media-controls>
<div #localVideo id="local-video"></div>
</div>

Next, open the local-stream.component.ts. First, import the MediaControls so we can load the MediaControls component as part of the local-stream component. Then, add a reference to the #localVideo so we can pass it to the Agora SDK to play the video in that <div/>. We'll need to add an EventEmitter to emit an event whenever users leave the channel. The next variable we'll declare is for the client which will come from the AgoraService, then we'll add a set of references to our local tracks (mic, video, and screen) along with localTracksActive for flagging the mute state of each track. Lastly, we'll set a subscription and its corresponding flag to keep track of when the AgoraService has joined a channel.

When the view inits for this component it triggers the local client to use the Agora SDK to initialize the local mic and camera tracks, and sets a listener to publish them once we join the channel. When the user leaves the channel, the app will remove the view which will trigger the handleLeave() to unpublish and close up all the tracks; releasing the mic and camera resources.

In the muteTrack() we use setEnabled() instead of setMuted(). While the effect is very similar for the remote users, the experience differs for the local users because setEnabled() it releases the mic/camera resources, indicating that their mic or camera is not active.

import { AfterViewInit, Component, ElementRef, EventEmitter, Output, ViewChild } from '@angular/core'
import { ILocalTrack, IAgoraRTCClient } from 'agora-rtc-sdk-ng';
import { AgoraService } from '../agora.service';
import { MediaControlsComponent } from '../media-controls/media-controls.component';
import { Subscription } from 'rxjs';

@Component({
selector: 'app-local-stream',
standalone: true,
imports: [MediaControlsComponent],
templateUrl: './local-stream.component.html',
styleUrl: './local-stream.component.css'
})
export class LocalStreamComponent implements AfterViewInit {
@ViewChild('localVideo', { static: true }) localVideo!: ElementRef<HTMLDivElement>;
@Output() leaveChannel = new EventEmitter<void>();

private client: IAgoraRTCClient;

private localMicTrack!: ILocalTrack;
private localVideoTrack!: ILocalTrack;
private localScreenTracks?: ILocalTrack[];

private channelJoined: boolean = false;
private subscription: Subscription = new Subscription();

private localTracksActive = {
audio: false,
video: false,
screen: false,
}

// Mapping to simplify getting/setting track-state
private trackNameMapping: { [key:string]: 'audio' | 'video' | 'screen' } = {
audio: 'audio',
video: 'video',
screen: 'screen',
}

constructor(private agoraService: AgoraService) {
this.client = this.agoraService.getClient()
}

async ngAfterViewInit(): Promise<void> {
[this.localMicTrack, this.localVideoTrack] = await this.agoraService.setupLocalTracks();
this.localTracksActive.audio = this.localMicTrack ? true : false
this.localTracksActive.video = this.localVideoTrack ? true : false
// play video track in localStreamComponent div
this.localVideoTrack.play(this.localVideo.nativeElement);
this.subscription.add(this.agoraService.channelJoined$.subscribe(status => {
this.channelJoined = status
if(status) {
this.publishTracks() // publish the tracks once we are in the channel
}
}))
}

async ngOnDestroy() {
// leave the channel if the component unmounts
this.handleLeaveChannel()
}

async publishTracks() {
await this.client.publish([ this.localMicTrack, this.localVideoTrack ])
}

async unpublishTracks() {
await this.client.publish([ this.localMicTrack, this.localVideoTrack ])
}

async handleLeaveChannel(): Promise<void> {
if(this.channelJoined) {
const tracks = [this.localMicTrack, this.localVideoTrack]
tracks.forEach(track => {
track.close()
})
await this.client.unpublish(tracks)
await this.agoraService.leaveChannel()
}
this.leaveChannel.emit()
}

async muteTrack(trackName: string, enabled: boolean): Promise<boolean> {
const track = trackName === 'mic' ? this.localMicTrack : this.localVideoTrack;
await track.setEnabled(enabled);
this.setTrackState(trackName, enabled)
return enabled;
}

async startScreenShare(): Promise<boolean> {
// TODO: add start screen share
// Listen for screen share ended event (from browser ui button)
// this.localScreenTracks[0]?.on("track-ended", () => {
// this.stopScreenShare()
// })
return true;
}

async stopScreenShare(): Promise<boolean> {
// TODO: add stop screenshare
return false;
}

getTrackState(trackName: string): boolean | undefined {
const key = this.trackNameMapping[trackName]
if (key) {
return this.localTracksActive[key]
}
console.log(`Get Track State Error: Unknown trackName: ${trackName}`)
return
}

setTrackState(trackName: string, state: boolean): void {
const key = this.trackNameMapping[trackName]
if (key) {
this.localTracksActive[key] = state
}
console.log(`Set Track State Error: Unknown trackName: ${trackName}`)
return
}

}

Moving on to the media-controls component, again we'll start with the HMTL, open the media-controls.component.html, and replace the placeholder tags with:

<div id="local-media-controls">
<button #micButton (click)="handleMicToggle($event)" class="media-active">Mic</button>
<button #videoButton (click)="handleVideoToggle($event)" class="media-active">Video</button>
<button #screenShareButton (click)="handleScreenShare($event)" class="media-active">Screen</button>
<button #leaveButton (click)="handleLeaveChannel()" class="media-active">End</button>
</div>

The structure is simple, a container and a set of buttons. Each button has its click event that we’ll define in media-controls.component.ts.

Next, open the media-controls.component.ts. This component is fairly light as it uses the LocalStreamComponent to mute and unmute the mic and camera, to start and stop Screen Sharing, and to leave the channel. The button states are updated using the classes media-active and muted.

import { Component, ElementRef, ViewChild } from '@angular/core'
import { LocalStreamComponent } from '../local-stream/local-stream.component';

@Component({
selector: 'app-media-controls',
standalone: true,
imports: [],
templateUrl: './media-controls.component.html',
styleUrl: './media-controls.component.css'
})
export class MediaControlsComponent {
// Buttons
@ViewChild('micButton', { static: true }) micButton!: ElementRef<HTMLButtonElement>;
@ViewChild('videoButton', { static: true }) videoButton!: ElementRef<HTMLButtonElement>;
@ViewChild('screenShareButton', { static: true }) screenShareButton!: ElementRef<HTMLButtonElement>;
@ViewChild('leaveButton', { static: true }) leaveButton!: ElementRef<HTMLButtonElement>;

constructor (private localStream: LocalStreamComponent) {}

handleMicToggle(e: Event): void {
const isActive = this.localStream.getTrackState('mic') // get active state
this.localStream.muteTrack('mic', !isActive)
this.toggleButtonActiveState(e.target as HTMLDivElement)
}

handleVideoToggle(e: Event): void {
const isActive = this.localStream.getTrackState('video') ?? false// get active state
this.localStream.muteTrack('video', !isActive)
this.toggleButtonActiveState(e.target as HTMLDivElement)
}

toggleButtonActiveState(button: HTMLDivElement): void {
button.classList.toggle('media-active') // Add/Remove active class
button.classList.toggle('muted') // Add/Remove muted class
}

handleScreenShare(e: Event): void {
const isActive = this.localStream.getTrackState('screen') // get active state
if (isActive) {
this.localStream.startScreenShare()
} else {
this.localStream.stopScreenShare()
}
this.toggleButtonActiveState(e.target as HTMLDivElement)
}

handleLeaveChannel(): void {
this.localStream.handleLeaveChannel()
}
}

Remote User Components

To create the remote user components, use the commands:

ng generate component remote-stream
ng generate component remote-user

This will create the following directories see the following directories and their respective files (.html, .css, .ts):

We’ll start with the HMTL, open the remote-stream.component.html and replace the placeholder tags with:

<div id="remote-video-container">
<ng-container #remoteVideoContainer ></ng-container>
</div>

The structure is simple, a <div /> that wraps an <ng-container />. The <ng-container /> allows for creating new components that use Angular's built-in functions to add/remove them to the parent container without affecting the rest of the DOM.

Next, open the remote-stream.component.ts. This component uses the client from the AgoraService to listen for remote users publishing and un-publishing their streams and manage them within the UI.

import { Component, OnInit, OnDestroy, ViewChild, ViewContainerRef, ComponentRef } from '@angular/core'
import { AgoraService } from '../agora.service';
import { IAgoraRTCClient, IAgoraRTCRemoteUser, UID } from 'agora-rtc-sdk-ng';
import { RemoteUserComponent } from '../remote-user/remote-user.component';

@Component({
selector: 'app-remote-stream',
standalone: true,
imports: [],
templateUrl: './remote-stream.component.html',
styleUrl: './remote-stream.component.css'
})
export class RemoteStreamComponent implements OnInit, OnDestroy {
client: IAgoraRTCClient;
remoteUserComponentRefs: Map<string, ComponentRef<RemoteUserComponent>>;

@ViewChild('remoteVideoContainer', { read: ViewContainerRef }) remoteVideoContainer!: ViewContainerRef;

constructor(private agoraService: AgoraService) {
this.client = this.agoraService.getClient()
this.remoteUserComponentRefs = new Map()
}

ngOnInit(): void {
// add listeners when component mounts
this.client.on('user-published', this.handleRemoteUserPublished)
this.client.on('user-unpublished', this.handleRemoteUserUnpublished)
}

ngOnDestroy(): void {
// remove listeners when component is removed
this.client.off('user-published', this.handleRemoteUserPublished)
this.client.off('user-unpublished', this.handleRemoteUserUnpublished)
}

private handleRemoteUserPublished = async (user: IAgoraRTCRemoteUser, mediaType: "audio" | "video" | "datachannel") => {
await this.client.subscribe(user, mediaType)
if (mediaType === 'audio') {
user.audioTrack?.play()
} else if (mediaType === 'video') {
const uid = user.uid
// create a remote user component for each new remote user and add to DOM
const remoteUserComponentRef: ComponentRef<RemoteUserComponent> = this.remoteVideoContainer.createComponent(RemoteUserComponent)
remoteUserComponentRef.instance.uid = uid
remoteUserComponentRef.instance.onReady = (remoteUserDiv) => {
user.videoTrack?.play(remoteUserDiv)
}
this.remoteUserComponentRefs.set(uid.toString(), remoteUserComponentRef)
}
}

private handleRemoteUserUnpublished = async (user: IAgoraRTCRemoteUser, mediaType: "audio" | "video" | "datachannel") => {
if(mediaType === 'video') {
const remoteUserUid = user.uid.toString()
// retrieve the div from remoteUserComponentRefs and remove it from DOM
const componentRef = this.remoteUserComponentRefs.get(remoteUserUid)
if(componentRef) {
const viewIndex = this.remoteVideoContainer.indexOf(componentRef?.hostView)
this.remoteVideoContainer.remove(viewIndex)
// remove entry from remoteUserComponentRefs
this.remoteUserComponentRefs.delete(remoteUserUid)
} else {
console.log(`Unable to find remoteUser with UID: ${user.uid}`)
}
}
}

clearRemoteUsers():void {
this.remoteVideoContainer.clear();
this.remoteUserComponentRefs.clear();
}
}

Moving to the remote-user component, open the HTML,remote-user.component.html and replace the placeholder tags with:

<div id="remote-user-{{uid}}-container" class="remote-video-container">
<div #remoteVideo id="remote-user-{{uid}}-video" class="remote-video"></div>
</div>

Next, open the remote-user.component.ts. This component is simple, it contains a UID to make each component distinct, a reference to the nested div, and a callback function onReady that executes after the view has initialized. This is important because the div must exist before we can pass it to the Agora SDK to add the <video /> element.

import { AfterViewInit, Component, ElementRef, Input, OnInit, ViewChild } from '@angular/core';
import { UID } from 'agora-rtc-sdk-ng';

@Component({
selector: 'app-remote-user',
standalone: true,
imports: [],
templateUrl: './remote-user.component.html',
styleUrl: './remote-user.component.css'
})
export class RemoteUserComponent implements OnInit, AfterViewInit {
@Input() uid!: UID;
@Input() onReady?: (element: HTMLElement) => void;
@ViewChild('remoteVideo') remoteVideo!: ElementRef<HTMLElement>;

constructor(private elementRef: ElementRef) {}

ngOnInit(): void {
console.log(`Remote user component initialized for UID: ${this.uid}`)
}

ngAfterViewInit(): void {
if (this.onReady){
this.onReady(this.remoteVideo.nativeElement)
}
}

get nativeElement(): HTMLElement {
return this.elementRef.nativeElement;
}

get remoteVideoDivId(): string {
return this.remoteVideo.nativeElement.id
}
}

Join Modal form Component

To demonstrate Agora’s flexibility, we’ll add a form that allows users to input the channel name to show how a single client can switch between multiple channels.

To create the join form component, use the command:

ng generate component join-modal
ng generate component remote-user

We’ll start with the HMTL, open the join-modal.component.html and replace the placeholder tags with:

 <div #modalOverlay id="overlay" class="modal">
<div id="modal-container">
<div id="modal-header">
<div id="title">
<h1>Join Channel</h1>
</div>
</div>
<form #joinChannelForm="ngForm" id="join-channel-form" (ngSubmit)="onSubmit(channelName.value, agoraToken.value)">
<div id="modal-body">
<div class="form-group">
<label for="form-channel-name">Channel Name</label>
<input ngModel name="channelName" #channelName="ngModel" type="text" id="form-channel-name" class="form-control">
<label for="form-agora-token">Token</label>
<input ngModel name="agoraToken" #agoraToken="ngModel" type="text" id="form-agora-token" class="form-control">
</div>
<div id="modal-footer">
<button type="submit" id="join-channel-btn">Join Channel</button>
</div>
</div>
</form>
</div>
</div>

Next, open the join-modal.component.ts. This component passes the Form data to the AgoraService to join the channel. The form emits the joinChannel event to the App component so it can remove the form and mount the local-stream

import { Component, ElementRef, EventEmitter, AfterViewInit, Output, ViewChild} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AgoraService } from '../agora.service';

@Component({
selector: 'app-join-modal',
standalone: true,
imports: [FormsModule],
templateUrl: './join-modal.component.html',
styleUrl: './join-modal.component.css'
})
export class JoinModalComponent implements AfterViewInit {

@ViewChild('modalOverlay') modalOverlay!: ElementRef<HTMLDivElement>;
@ViewChild('joinChannelForm') joinChannelForm!: ElementRef<HTMLFormElement>;
@Output() joinChannel = new EventEmitter<void>();

constructor(private agoraService: AgoraService) {}

ngAfterViewInit () {
// show the modal once the page has loaded
this.modalOverlay.nativeElement.classList.add('show')
}

async onSubmit(channelName: string, agoraToken?: string, userID?: string) {
await this.agoraService.joinChannel(channelName, agoraToken ?? null, userID ?? null)
this.joinChannel.emit() // notify the app to hide the model and show the local video
}
}

App component

Now that we have the majority of the integration set up, we can bring everything together in the App component. Starting with the HMTL, open the app.component.html and replace the placeholder with:

<main id="app-container" class="main">
<app-remote-stream #remoteStreamsContainer></app-remote-stream>
<app-local-stream *ngIf="isLocalStreamVisible" (leaveChannel)="handleLeaveChannel()"></app-local-stream>
<app-join-modal *ngIf="isJoinModalVisible" (joinChannel)="handleJoinChannel()"></app-join-modal>
</main>
<router-outlet />

Next, open the app.component.ts. This component imports the component we created and manages the visibility of the join-modal and localStream, which in turn manages the user connections to the channel and emits events to the app that update the visibility of the two elements.

import { Component, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';

import { JoinModalComponent } from './join-modal/join-modal.component';
import { RemoteStreamComponent } from './remote-stream/remote-stream.component';
import { LocalStreamComponent } from './local-stream/local-stream.component';

@Component({
selector: 'app-root',
standalone: true,
imports: [
CommonModule,
RouterOutlet,
JoinModalComponent,
RemoteStreamComponent,
LocalStreamComponent,
],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {

@ViewChild('remoteStreamsContainer') remoteStreamsComponent!: RemoteStreamComponent;
title = 'agora-angular-demo';

isJoinModalVisible = true;
isLocalStreamVisible = false;

handleJoinChannel() {
this.isJoinModalVisible = false;
this.isLocalStreamVisible = true;
}

handleLeaveChannel() {
this.isLocalStreamVisible = false;
this.isJoinModalVisible = true;
this.remoteStreamsComponent.clearRemoteUsers()
}
}

You have successfully integrated the Agora Video SDK! 🎉

Test the code

To test that everything was set up correctly, start the local server:

ng serve

Open the URL http://localhost:4200/ in your browser.

To simulate multiple users, open multiple tabs and use the same channel name.

Next Steps

That’s it! Not so difficult right? Now you’re ready to integrate Agora’s real-time voice and video features into your Angular project Here are a few more steps to keep you going on your journey with Agora:

  1. Explore Agora’s other SDKs such as real-time messaging with Agora Signaling, or robust in-app chat features with Agora Chat.
  2. Make your live video communication secure: implement authentication mechanisms, end-to-end encryption, and other security measures.
  3. Explore Agora’s scalability features: If your application anticipates a large number of users, understand how to scale your application to accommodate a growing user base while maintaining performance and reliability.

Other Resources

  • Dive into the Agora Documentation to better understand the features and capabilities of the Agora SDKs. Explore the API reference, sample codes, and best practices.
  • Be part of the Agora developer community: Join the conversation on X(Twitter), or LinkedIn to share experiences, and stay updated on the latest developments. Need support? Reach out via StackOverflow for advice on your implementation.
  • Continue your developer journey with more guides from Agora’s Medium publication.

--

--

Hermes
Agora.io

Director of DevRel @ Agora.io … former CTO @ webXR.tools & AR Engineer @ Blippar — If you can close your eyes & picture it, I can find a way to build it