Manage Daily video call state in Angular (Part 2)

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:

  1. Build the join-form component to allow users to join a Daily room.
  2. Show how the app-daily-container component works, including how it responds to the join-form being submitted.
  3. See how the app-call component sets up an instance of the Daily Client SDK’s call object.
  4. 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:

Component structure in the Angular demo app.
Component structure in the Angular demo app.

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.

Gif of call participant submitting the join form and joining the video call
Submitting the form in `join-form` will cause `app-daily-container` to render `app-call` instead.

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

Join form UI
The demo app’s join-form component.

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>
💡
You can create a Daily room and get its URL through the Daily dashboard or REST API.

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:

  1. Create an instance of the call object (i.e., the DailyIframe class) using the createCallObject() factory method. (The getCallInstance() static method can be used to see if a call object already exists, too.)
  2. 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.
  3. Join the call using the dailyRoomUrl and the userName, which were provided in the join-form. This is done via the join() 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.

💡
The next post in this series will cover the track-related events.

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:

  1. A reformatted copy of the participant object is made.
  2. 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:

  1. The video and audio tracks.
  2. Whether the tracks can be played in the demo’s UI, which is represented by videoReady and audioReady.
  3. The user name of the participant, which we’ll display in the UI.
  4. Whether they’re local, since local participants will have device controls in their video-tile.
  5. 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.

Never miss a story

Get the latest direct to your inbox.