Video Chat with LiveView and OpenTok

Tags

Video Chat

In my previous post, Live Streaming with LiveView and Mux, I explored how simple it was to integrate Phoenix LiveView with Mux to live stream video. As it turned out, it was very simple: under 70 lines of code. While the technique we used in that post would work great for live streaming events or conference talks, the slight delay wouldn’t work for real-time communication such as video chat.

In this post, I will explore how Phoenix LiveView can be used to quickly and easily engineer a video chat solution. This time we will be using OpenTok, which is now part of Vonage as the Vonage Video API. The post will also be slightly different in that it is not a step-by-step tutorial, I will instead walk you through the example repository and explain the logic behind the code. As expected, the full video chat required more lines of code than our uni-directional streaming example did, but it still came in under 350 lines (~125 of those for the lobby).

You can view the example repository here, and as with the previous post, you can see the original commit was made after simply running mix phx.new --live --no-ecto opentok_liveview, and all subsequent code beyond the generated code is in this commit.

Lobby and Room Master

Room Master

First, let’s take a quick look at the logic in the example that takes place before we get to the actual video. In Elixir, we store and access ephemeral system-wide state in a GenServer. For our example, we store room and user information in the RoomMaster. We won’t get into the details of GenServers, but for our purposes, we need to know that the RoomMaster keeps track of the list of rooms, who is in each room, and details about each user such as their username, stream identifier (once they start streaming), their private token, and a reference to their LiveView process ID so we can notify them when their room details change.

When someone tries to join a room that doesn’t yet exist, the RoomMaster generates an OpenTok session for the room and adds the room to its list:

  defp get_or_create_room(rooms, room_name) do
    rooms
    |> Enum.split_with(&(&1.name == room_name))
    |> case do
      {[], _} ->
        Logger.info("creating room #{room_name}")
        {%{name: room_name, users: [], session_id: generate_session_id()}, rooms}

      {[room], other_rooms} ->
        {room, other_rooms}
    end
  end

  …

  defp generate_session_id do
    %{"session_id" => session_id} = ExOpentok.init()
    session_id
  end

As you can see, we are using the ExOpentok library for server-side communication with OpenTok, generating the session id with ExOpentok.init().

When the RoomMaster needs to add a user to the room, it again uses ExOpentok to generate a token for the room’s session, through the call ExOpentok.Token.generate(session_id) and stores the new users information in its state for that room:

    existing_users
    |> Enum.find(&(&1.name == username))
    |> case do
      nil ->
        Logger.info("adding #{username} to #{room_name}")
        token = ExOpentok.Token.generate(session_id)
        users = [%{name: username, pid: pid, token: token, stream_id: nil} | existing_users]
        room = %{room | users: users}
        Enum.map(existing_users, fn %{pid: pid} -> send(pid, {:room_updated, room}) end)
        rooms = [room | other_rooms]
        {:reply, {:ok, room}, %{state | rooms: rooms}}

      _ ->
        {:reply, {:error, "user already in room"}, %{state | rooms: rooms}}
    end

As you can see, we also loop through the existing users and let them know that someone is joining the room through a :room_updated message. We will see how LiveView handles this incoming message when we get to the Room LiveView page in a bit.

Lobby

The default PageView generated by LiveView has become the lobby. The PageView template and the PageView LiveView show how concise the declarative nature of LiveView is, with the template coming in at under 40 lines of code, and the LiveView just assigning the current list of rooms as retrieved from the RoomMaster on mount, and handling some basic events that either update the assigns or redirect the user to a room.

The Video Chat Room

All of this, however, is setup for where the real video magic actually happens, the Room LiveView. There are three parts to the Room LiveView: the template, the LiveView, and the javascript.

The Room Template

First, let’s take a look at the template:

<h1><%= @room_name %></h1>
<%= if !is_nil(@room) do %>
<div style="display: flex;">
  <div>
    <%= @username %> (you)<br>
    <div phx-update="ignore">
      <div phx-hook="PublisherInit" id="publisher-div"></div>
    </div>
  </div>
  <%= for user <- others_in_room(@room, @username) do %>
    <div id="user-<%= user.name %>" style="margin-left: 10px">
      <%= user.name %><br>
      <%= if is_nil(user.stream_id) do %>
        Connecting to Video...
      <% else %>
        <div phx-update="ignore">
          <div id="subscriber-div-<%= user.stream_id %>"></div><br>
        </div>
      <% end %>
      <br>
    </div>
  <% end %>
</div>

<br><br>
  <%= inspect(@room) %>
<% end %>

The template defines one publisher div for the viewer’s camera, and one subscriber for every other user in the room. The publisher div includes phx-hook=”PublisherInit”, which will trigger a javascript call when the LiveView mounts. We will see that in a second when we get to the JavaScript file. Note that the id of each subscriber includes the stream_id for that user. This will allow us to tie the OpenTok call for each user to the correct div, putting each user under their username. At the end you see that we are inspecting the room assigns variable with this call <%= inspect(@room) %>. LiveView makes it really easy to add debug information like this to the page to help speed development along.

The Room LiveView

In the LiveView mount, we see that the username and room name from the URL are captured and stored in the assigns, and then if the LiveView is connected, it will ask the RoomMaster to join the room, assigning the room details if successful:

  @impl Phoenix.LiveView
  def mount(%{"room_name" => room_name, "username" => username} = _params, _session, socket) do
    socket =
      socket
      |> assign(:username, username)
      |> assign(:room_name, room_name)
      |> assign(:room, nil)

    if connected?(socket) do
      case RoomMaster.join_room(room_name, username, self()) do
        {:ok, room} ->
          {:ok, assign(socket, :room, room)}

        {:error, reason} ->
          {:ok, socket |> put_flash(:error, reason) |> redirect(to: "/")}
      end
    else
      {:ok, socket}
    end
  end

We also see how this LiveView handles the :room_updated message that we saw referenced in the RoomMaster. When this message is received, the LiveView simply assigns the new room information. This will cause the UI to update and the new user will appear in the browser:

  @impl Phoenix.LiveView
  def handle_info({:room_updated, room}, socket) do
    {:noreply, assign(socket, :room, room)}
  end

The remainder of the LiveView file is part of the dance that happens back and forth between the JavaScript and the LiveView. We will follow that dance back and forth in order, so let’s jump over to the JavaScript file for now.

LiveView and JavaScript

One of the major benefits of LiveView is that it eliminates writing most, if not all, JavaScript necessary to create rich interactive experiences on the web. One place where you will need to write a bit of JavaScript is when interacting with client libraries that themselves are written in JavaScript. This is the case we have here: the OpenTok library is a JavaScript library. We include this library in our application by simply adding a script tag to the root layout LiveView template.

Now, let’s walk through the dance between the JavaScript file and the LiveView. As you recall, the template contained a div with the id publisher-div and phx-hook=”PublisherInit”. In the JavaScript file, we see that PublisherInit hook defined:

Hooks.PublisherInit = {
  mounted() {
    this.pushEvent("get_publish_info", {}, (reply, ref) => {

The mounted() function indicates what should happen when the LiveView mounts. In this case, the first thing we do on mount is send a request back to the LiveView server with the event “get_publish_info”. Looking back at the LiveView, we see the handler for this event:

  @impl Phoenix.LiveView
  def handle_event("get_publish_info", _, socket) do
    %{token: token} = get_me(socket)
    {:reply, %{key: get_key(), token: token, session_id: socket.assigns.room.session_id}, socket}
  end

The reply for this event will include the key, token, and session_id. Back in the JavaScript, we see how these reply values are used:

    this.pushEvent("get_publish_info", {}, (reply, ref) => {
      session = OT.initSession(reply.key, reply.session_id);
      publisher = OT.initPublisher('publisher-div', {}, handleError);
      session.connect(reply.token, (error) => {
        if (error) {
          handleError(error);
        } else {
          session.publish(publisher, (error) => {
            if (error) {
              console.log(error);
            } else {
              setTimeout(() => {
                this.pushEvent("store_stream_id", {stream_id: publisher.streamId}, (reply, ref) => {});
              });
            }
          });
        }
      });
      session.on("streamCreated", (event) => {
        subscribeWhenReady(session, event.stream, event.stream.id);
      });
    });

First, we initialize the OpenTok session with the key and session id. We then initialize an OpenTok Publisher and point it at the publisher-div we included in the template. OpenTok will take over that div and populate it with their viewer and controls. We then connect to the session with our unique token we generated on the server. Once connected, we start publishing to the session. If this is successful, we will get back a stream id, which we can give to the server so other clients know which user is tied to which stream. This will update everyone else’s LiveView with a subscriber-div as we saw in the template:

<div id="subscriber-div-<%= user.stream_id %>"></div>

We also tell the session how to handle the “streamCreated” event, which triggers for this user when any other users join the session. It calls this function:

function subscribeWhenReady(session, stream, id) {
  const target = document.getElementById('subscriber-div-' + id);
  if (target) {
    session.subscribe(stream, 'subscriber-div-' + id);
  } else {
    setTimeout(() => {subscribeWhenReady(session, stream, id)}, 500);
  }
}

Which will wait until the LiveView has updated with the new subscriber div id, and then tells OpenTok to use that div to display the new stream.

The end result is that this user’s video is playing in the Publisher div, and one Subscriber div exists for each other user, labeled with their username, and subscribed to their video stream.

Video Chat

Running the Example

You should be able to run the example repo as is with the following changes:

  • In config/config.exs replace the key and secret inside config :ex_opentok with your own values. Sign up for a free TokBox account and create a new “OpenTok API” project. The key and secret will appear at the top of the project.

  • If you want to connect to your server with multiple devices, you will need to enable https by adding the following https section to config\dev.exs:

config :opentok_liveview, OpentokLiveviewWeb.Endpoint,
  http: [port: 4000],
  https: [
    port: 4001,
    cipher_suite: :strong,
    certfile: "priv/cert/selfsigned.pem",
    keyfile: "priv/cert/selfsigned_key.pem"
  ],
  debug_errors: true,

and then run mix phx.gen.cert server_ip_address from the root of the directory.

Interested in integrating video into your web offering? Reach out today to consult with our expert team.

DockYard is a digital product consultancy specializing in user-centered web application design and development. Our collaborative team of product strategists help clients to better understand the people they serve. We use future-forward technology and design thinking to transform those insights into impactful, inclusive, and reliable web experiences. DockYard provides professional services in strategy, user experience, design, and full-stack engineering using Ember.js, React.js, Ruby, and Elixir. From ideation to delivery, we empower ambitious product teams to build for the future.