In this series, we’re building a video call app with a fully customized UI using Angular and Daily’s Client SDK for JavaScript. In the first post, we reviewed the app’s core features, as well as the general code structure and the role of each component. (Instructions for setting up the demo app locally are also included there.)
In today’s post, we’ll start digging into the Angular demo app’s source code available on GitHub. More specifically, we will:
- Build the
join-form
component to allow users to join a Daily room. - Show how the
app-daily-container
component works, including how it responds to thejoin-form
being submitted. - See how the
app-call
component sets up an instance of the Daily Client SDK’s call object. - Review how joining a Daily call works, and how to handle the events related to joining and leaving a call.
The next post in this series will cover rendering the actual video tiles for each participant, so stay tuned for that.
Now that we have a plan, let’s get started!
app-daily-container
: The parent component
As a reminder from our first post, app-daily-container
is the parent component for the video call feature:
It includes the join-form
component, which allows users to submit an HTML form to join a Daily call. It also includes the app-call
component, which represents the video call UI shown after the form is submitted.
Submitting the form in join-form
will cause app-daily-container
to render app-call
instead.
In this sense, app-daily-container
is the bridge between the two views because we need to share the form values obtained via join-form
with the in-call components (app-call
and its children).
app-chat
is also a child of app-daily-container
, but we’ll cover that in a future post.Looking at the class definition for app-daily-container
, we see there are two class variables (dailyRoomUrl
and userName
), as well as methods to set or reset those variables.
import { Component } from "@angular/core";
@Component({
selector: "app-daily-container",
templateUrl: "./daily-container.component.html",
styleUrls: ["./daily-container.component.css"],
})
export class DailyContainerComponent {
// Store callObject in this parent container.
// Most callObject logic in CallComponent.
userName: string;
dailyRoomUrl: string;
setUserName(name: string): void {
// Event is emitted from JoinForm
this.userName = name;
}
setUrl(url: string): void {
// Event is emitted from JoinForm
this.dailyRoomUrl = url;
}
callEnded(): void {
// Truthy value will show the CallComponent; otherwise, the JoinFormComponent is shown.
this.dailyRoomUrl = "";
this.userName = "";
}
}
The HTML for app-daily-container
shows how these variables and methods get shared with the child components:
<join-form
*ngIf="!dailyRoomUrl"
(setUserName)="setUserName($event)"
(setUrl)="setUrl($event)"></join-form>
<app-call
*ngIf="dailyRoomUrl"
[userName]="userName"
[dailyRoomUrl]="dailyRoomUrl"
(callEnded)="callEnded()"></app-call>
We can see that the join-form
and app-call
components use the *ngIf
attribute to determine when they should be rendered. They have opposite conditions, meaning they will never both be rendered at the same time. If the dailyRoomUrl
variable is not set yet, it renders the join-form
(the default view); otherwise, it renders the app-call
component. This is because the dailyRoomUrl
is set when the join form has been submitted, so if the value isn’t set, the form hasn’t been used yet.
join-form
has two output properties: the setUserName()
and setUrl()
event emitters, which are invoked when the join form is submitted.
app-call
receives the userName
and dailyRoomUrl
variables as input props, which automatically update when Angular detects a change in their values. It also has callEnded()
as an output prop.
Understanding input and output props in Angular
In Angular, props can either be an input property – a value passed down to the child – or an output property – a method used to emit a value from the child to the parent component. (Data sharing is bidirectional between the parent and child when both types are used.)
You can tell which type of property it is based on whether the child component declares the prop as an input or output. For example, in app-call
we see:
@Input() userName: string;
@Output() callEnded: EventEmitter<null> = new EventEmitter();
The userName
prop is an input property, meaning the value will automatically update in app-call
whenever the parent component updates it.
The callEnded
prop is an output property. Whenever app-call
wants to emit an update to the parent component (app-daily-container
), it can call this.callEnded(value)
and app-daily-container
will receive the event and event payload.
join-form
: Submitting user data for the call
The join-form
component renders an HTML form element. It includes two inputs for the user to provide the Daily room they want to join, as well as their name:
<form [formGroup]="joinForm" (ngSubmit)="onSubmit()" class="join-form">
<label for="name">Your name</label>
<input type="text" id="name" formControlName="name" required />
<label for="url">Daily room URL</label>
<input type="text" id="url" formControlName="url" required />
<input type="submit" value="Join call" [disabled]="!joinForm.valid" />
</form>
The onSubmit()
class method is invoked when the form is submitted (ngSubmited
).
The input values are bound to the component’s joinForm
values, an instance of Angular’s FormBuilder
class:
// … See the source code for the complete component
export class JoinFormComponent {
@Output() setUserName: EventEmitter<string> = new EventEmitter();
@Output() setUrl: EventEmitter<string> = new EventEmitter();
joinForm = this.formBuilder.group({
name: "",
url: "",
});
constructor(private formBuilder: FormBuilder) {}
onSubmit(): void {
const { name, url } = this.joinForm.value;
if (!name || !url) return;
// Clear form inputs
this.joinForm.reset();
// Emit event to update userName var in parent component
this.setUserName.emit(name);
// Emit event to update URL var in parent component
this.setUrl.emit(url);
}
}
When onSubmit()
is invoked, the form input values are retrieved through this.joinForm.value
. The this.joinForm
values are then reset, which also resets the inputs in the form UI to visually indicate to the user that the form was successfully submitted.
Next, the setUserName()
and setURL()
output properties are used to emit form input values to app-daily-container
. (Remember: app-daily-container
is listening for events already.)
Once these events are received by app-daily-container
, the form values are assigned to the userName
and dailyRoomUrl
variables in app-daily-container
, the latter of which causes the app-call
component to be rendered instead of the join-form
.
It’s a mouthful, but now we can move on to creating the actual video call!
app-call
: The brains of this video call operation
Let’s start with app-call
’s HTML so we know what’s going to be rendered:
<error-message
*ngIf="error"
[error]="error"
(reset)="leaveCall()"></error-message>
<p *ngIf="dailyRoomUrl">Daily room URL: {{ dailyRoomUrl }}</p>
<p *ngIf="!isPublic">
This room is private and you are now in the lobby. Please use a public room to
join a call.
</p>
<div *ngIf="!error" class="participants-container">
<video-tile
*ngFor="let participant of Object.values(participants)"
(leaveCallClick)="leaveCall()"
(toggleVideoClick)="toggleLocalVideo()"
(toggleAudioClick)="toggleLocalAudio()"
[joined]="joined"
[videoReady]="participant.videoReady"
[audioReady]="participant.audioReady"
[userName]="participant.userName"
[local]="participant.local"
[videoTrack]="participant.videoTrack"
[audioTrack]="participant.audioTrack"></video-tile>
</div>
There is an error message that gets shown only if the error
variable is true. (This gets set when Daily’s Client SDK’s ”error”
event is emitted.)
The dailyRoomUrl
value is rendered in the UI so the local participant can see which room they joined. We also let the participant know if the room is private since we haven’t added a feature yet to join a private call. (You could, for example, add a knocking feature).
Finally, the most important part: the video tiles. In Angular, you can use *ngFor
attribute like a for of
statement. For each value in the participants
object (described in more detail below), a video-tile
component will be rendered. We will look at how the video-tile
component works in the next post so, for now, know that there’s one for each participant.
Next, let’s look at how we build our participants
object in app-call
’s class definition.
Defining the CallComponent
class
Most of the logic related to interacting with Daily’s Client SDK for JavaScript is included in the app-call
component. The daily-js
library is imported at the top of the file (import DailyIframe from "@daily-co/daily-js";
) which allows us to create the call for the local participant.
To build our call, we will need to:
- Create an instance of the call object (i.e., the
DailyIframe
class) using thecreateCallObject()
factory method. (ThegetCallInstance()
static method can be used to see if a call object already exists, too.) - Attach event listeners to the call object.
daily-js
will emit events for any changes in the call so we’ll listen for the ones relevant to our demo use case. - Join the call using the
dailyRoomUrl
and theuserName
, which were provided in thejoin-form
. This is done via thejoin()
instance method.
Since the call component is rendered as soon as the join form is submitted, we need to set up the call logic as soon as the component is initialized. In Angular, we use the ngOnInit()
lifecycle method to capture this:
// … See source code for more detail
export class CallComponent {
Object = Object; // Allows us to use Object.values() in the HTML file
@Input() dailyRoomUrl: string;
@Input() userName: string;
// .. See source code
ngOnInit(): void {
// Retrieve or create the call object
this.callObject = DailyIframe.getCallInstance();
if (!this.callObject) {
this.callObject = DailyIframe.createCallObject();
}
// Add event listeners for Daily events
this.callObject
.on("joined-meeting", this.handleJoinedMeeting)
.on("participant-joined", this.participantJoined)
.on("track-started", this.handleTrackStartedStopped)
.on("track-stopped", this.handleTrackStartedStopped)
.on("participant-left", this.handleParticipantLeft)
.on("left-meeting", this.handleLeftMeeting)
.on("error", this.handleError);
// Join Daily call
this.callObject.join({
userName: this.userName,
url: this.dailyRoomUrl,
});
}
// … See source code
All three of the steps mentioned above happen as soon as the component is initialized. The second step – attaching Daily event listeners – is where most of the heavy lifting happens for managing the call. For each event shown in the code block above, an event handler is attached that will be invoked when the associated event is received.
As an overview, each event used above represents the following:”joined-meeting”
: The local participant joined the call.”participant-joined”
: A remote participant joined the call.”track-started”
: A participant's audio or video track began.”track-stopped”
: A participant's audio or video track ended.”participant-left”
: A remote participant left the call.”left-meeting”
: The local participant left the call.”error”
: Something went wrong.
Once the call object is initialized and join()
-ed, we need to manage the call state related to which participants are in the call. Once we know who is in the call, we can render video-tile
components for them.
Adding a new participant
The app-call
component uses the participants
variable (an empty object to start) to track all participants currently in the call.
export type Participant = {
videoTrack?: MediaStreamTrack | undefined;
audioTrack?: MediaStreamTrack | undefined;
videoReady: boolean;
audioReady: boolean;
userName: string;
local: boolean;
id: string;
};
type Participants = {
[key: string]: Participant;
};
export class CallComponent {
// … See source code
participants: Participants = {};
When the local participant or a new remote participant joins, we’ll respond the same way: by updating participants
.
In both this.handleJoinedMeeting()
and this.participantJoined()
– the callbacks for ”joined-meeting”
and ”participant-joined”
– the this.addParticipant()
method gets called to update participants
. Let’s see how this works using this.participantJoined()
as an example:
participantJoined = (e: DailyEventObjectParticipant | undefined) => {
if (!e) return;
// Add remote participants to participants list used to display video tiles
this.addParticipant(e.participant);
};
With any Daily event, the event payload is available in the callback method (e
in this case). The participant
information is available in the event payload, which then gets passed to this.addParticipant()
.
addParticipant(participant: DailyParticipant) {
const p = this.formatParticipantObj(participant);
this.participants[participant.session_id] = p;
}
Two steps happen here:
- A reformatted copy of the participant object is made.
- The reformatted participant object gets added to the
participants
object.
The participant object doesn’t need to get reformatted for this demo to work, but we do this to make the object easier to work with. The participant object that gets returned in the event payload contains a lot of nested information – much of which doesn’t affect our current feature set. To extract the information we do care about, we use this.formatParticipantObj()
to format a copy of the participant object, like so:
formatParticipantObj(p: DailyParticipant): Participant {
const { video, audio } = p.tracks;
const vt = video?.persistentTrack;
const at = audio?.persistentTrack;
return {
videoTrack: vt,
audioTrack: at,
videoReady:
!!(vt && (video.state === PLAYABLE_STATE || video.state === LOADING_STATE)),
audioReady:
!!(at && (audio.state === PLAYABLE_STATE || audio.state === LOADING_STATE)),
userName: p.user_name,
local: p.local,
id: p.session_id,
};
}
What we’re interested in here is:
- The video and audio tracks.
- Whether the tracks can be played in the demo’s UI, which is represented by
videoReady
andaudioReady
. - The user name of the participant, which we’ll display in the UI.
- Whether they’re local, since local participants will have device controls in their
video-tile
. - The participant ID, so we can have a unique way of identifying them.
(Refer to our docs for an example of all the other participant information returned in the event.)
Once the participant has been added to the participants
object, a video-tile
can be rendered for them, but more on that in our next post!
Removing a remote participant from the call
A call participant can leave the call in different ways. For example, they can use the “Leave” button in the video-tile
component or they can simply exit their browser tab for the app. In any case, we need to know when a participant has left to properly update the app UI.
When any remote participant leaves the call, we need to update participants
to remove that participant.
handleParticipantLeft = (
e: DailyEventObjectParticipantLeft | undefined
): void => {
if (!e) return;
console.log(e.action);
delete this.participants[e.participant.session_id];
};
In handleParticipantLeft()
(the handler for the ”participant-left”
event), we simply delete the entry for that participant in the participants
object. This will in turn remove their video tile from the call UI.
Removing a local participant from the call
When the local participant leaves, we need to reset the UI by unmounting the app-call
component and going back to rendering the join-form
view instead, since the participant is no longer in the call. Instead of updating participants
, we can just destroy()
the call object and emit callEnded()
, the output property passed from app-daily-container
.
handleLeftMeeting = (e: DailyEventObjectNoPayload | undefined): void => {
if (!e) return;
console.log(e);
this.joined = false; // this wasn’t mentioned above but is used in the UI
this.callObject.destroy();
this.callEnded.emit();
};
When callEnded()
is emitted, app-daily-container
will reset the dailyRoomUrl
and userName
class variables.
// In DailyContainerComponent:
callEnded(): void {
// Truthy value will show the CallComponent; otherwise, the JoinFormComponent is shown.
this.dailyRoomUrl = "";
this.userName = "";
}
As a reminder, when dailyRoomUrl
is falsy, the join-form
component gets rendered instead of the app-call
.
And with that, we have a Daily call that can be joined and left!
Conclusion
In today’s post, we covered how participants can submit an HTML form in an Angular app to join a Daily video call, as well as how to manage participant state when participants join or leave the call.
In the next post, we’ll discuss how to render video tiles for each participant while they’re in the call, including our recommendations for optimizing video performance in an app like this.
If you have any questions or thoughts about implementing your Daily-powered video app with Angular, reach out to our support team or head over to our Discord community.