Persisting Annotations

Now that we have in-memory annotations going across all connected clients through an authenticated user, let’s extend our multimedia context to attach those annotations to videos and users in the database. You’ve seen how we manage schemas and relationships with Ecto so the process will be straightforward. In this case, we’re creating annotations on videos. Each new annotation will belong to both a user and a video.

You can use the phx.gen.schema generator, like this:

 $ ​​mix​​ ​​phx.gen.schema​​ ​​Multimedia.Annotation​​ ​​annotations​​ ​​body:text​​ ​​
 at:integer​​ ​​user_id:references:users​​ ​​video_id:references:videos
 
 * creating lib/rumbl/multimedia/annotation.ex
 * creating priv/repo/migrations/20180726203443_create_annotations.exs
 
 Remember to update your repository by running migrations:
 
  $ ​​mix​​ ​​ecto.migrate
 $
 ----​​ ​​END​​ ​​OF​​ ​​OUTPUT​​ ​​----

And now you can migrate your database:

 $ ​​mix​​ ​​ecto.migrate
 
 [info] == Running Rumbl.Repo.Migrations.CreateAnnotations.change/0 forward
 [info] create table annotations
 [info] create index annotations_user_id_index
 [info] create index annotations_video_id_index
 [info] == Migrated in 0.1s
 
 $

Our migrations are in, with our new table and two new indexes.

Next, we need to wire up our new relationships to our Accounts.User and Multimedia.Video schemas. Both users and videos will have annotations, but we need to decide where to surface these schema details within our contexts. Details like these will make or break an API. Allowing access to every possible type of data that may ever be associated with a user would probably lead to a tedious, bloated API and conflate the purpose of our Accounts context. We’ll provide only enough functions to comfortably do the job at hand.

For now, we’ll tentatively expose annotations strictly through the Multimedia context since the Accounts.User schema should not need to know about Multimedia.Annotations. If an application needs change in the future, we can revisit this decision. Add the has_many relationship to the Multimedia.Video schema blocks in lib/rumbl/multimedia/video.ex, like this:

 has_many ​:annotations​, Rumbl.Multimedia.Annotation

Next, let’s update our generated Annotation schema in lib/rumbl/multimedia/annotation.ex. Right now, both user_id and video_id are simple schema fields. We’ll want to manage annotation lists through Ecto so let’s upgrade them to be first class belongs_to relationships, like this:

 schema ​"​​annotations"​ ​do
  field ​:at​, ​:integer
  field ​:body​, ​:string
 
  belongs_to ​:user​, Rumbl.Accounts.User
  belongs_to ​:video​, Rumbl.Multimedia.Video
 
  timestamps()
 end

We will want to read and write our video annotations from within our channels. Just as we did within our controllers, we’ll want to access those features from our Multimedia context rather than the schema. Let’s add those features to our context. Open up lib/rumbl/multimedia.ex and add functions to list and create annotations, like so:

1: alias Rumbl.Multimedia.Annotation
def​ annotate_video(%Accounts.User{​id:​ user_id}, video_id, attrs) ​do
%Annotation{​video_id:​ video_id, ​user_id:​ user_id}
5:  |> Annotation.changeset(attrs)
|> Repo.insert()
end
def​ list_annotations(%Video{} = video) ​do
10:  Repo.all(
from a ​in​ Ecto.assoc(video, ​:annotations​),
order_by:​ [​asc:​ a.at, ​asc:​ a.id],
limit:​ 500,
preload:​ [​:user​]
15:  )
end

On line 3, we added an annotate_video function, which accepts a user, video ID, and attributes for the annotation. In that function, we build an annotation struct with the video ID and user ID. We pipe that struct to changeset to create our changeset, and then pipe the completed record to Repo.insert. We could have used Ecto.Changeset.put_assoc to put both user and video associations, but setting the foreign keys directly gives the same end result.

To fetch a list of annotations for a given video, we defined the list_annotations on line 9. It’s just a simple Ecto query. We put in a high limit to make sure we don’t bring back too many records to handle, and we preloaded the user. Remember, if you want to use data in an association, you need to fetch it explicitly. You’ve seen queries like this before in Chapter 6, Generators and Relationships.

Now, all that remains is to head back to our VideoChannel and integrate our callbacks to use the new context features. Open up the video channel and make these modifications:

1: alias Rumbl.{Accounts, Multimedia}
def​ join(​"​​videos:"​ <> video_id, _params, socket) ​do
{​:ok​, assign(socket, ​:video_id​, String.to_integer(video_id))}
5: end
def​ handle_in(event, params, socket) ​do
user = Accounts.get_user!(socket.assigns.user_id)
handle_in(event, params, user, socket)
10: end
def​ handle_in(​"​​new_annotation"​, params, user, socket) ​do
case​ Multimedia.annotate_video(user, socket.assigns.video_id, params) ​do
{​:ok​, annotation} ->
15:  broadcast!(socket, ​"​​new_annotation"​, %{
id:​ annotation.id,
user:​ RumblWeb.UserView.render(​"​​user.json"​, %{​user:​ user}),
body:​ annotation.body,
at:​ annotation.at
20:  })
{​:reply​, ​:ok​, socket}
{​:error​, changeset} ->
{​:reply​, {​:error​, %{​errors:​ changeset}}, socket}
25: end
end

First, we ensure that all incoming events have the current user by defining a new handle_in/3 function on line 7. It catches all incoming events, looks up the user from the socket assigns, and then calls a handle_in/4 clause with the socket user as a third argument.

Next, we call our Multimedia.annotate_video function. On success, we broadcast to all subscribers as before. Otherwise, we return a response with the changeset errors. After we broadcast, we acknowledge the success by returning {:reply, :ok, socket}.

We could have decided not to send a reply with {:noreply, socket}, but it’s common practice to acknowledge the result of the pushed message from the client. This approach allows the client to easily implement UI features such as loading statuses and error notifications, even if we’re only replying with an :ok or :error status and no other information.

Since we also want to notify subscribers about the user who posted the annotation, we render a user.json template from our UserView on line 17. Let’s implement that now:

 defmodule​ RumblWeb.UserView ​do
 use​ RumblWeb, ​:view
  alias Rumbl.Accounts
 
 def​ first_name(%Accounts.User{​name:​ name}) ​do
  name
  |> String.split(​"​​ "​)
  |> Enum.at(0)
 end
 
 def​ render(​"​​user.json"​, %{​user:​ user}) ​do
  %{​id:​ user.id, ​username:​ user.username}
 end
 end

Now let’s head back to the app and post a few annotations. Watch your server logs as the posts are submitted, and you can see your insert logs:

 [debug] INCOMING "new_annotation" on "videos:1" to RumblWeb.VideoChannel
  Parameters: %{"at" => 0, "body" => "testing"}
 
 begin []
 [debug] QUERY OK db=20.3ms
 INSERT INTO "annotations" ("at","body","user_id","video_id",...
 [debug] QUERY OK db=0.6ms
 commit []

And we have persisted data!

We have a problem, though. Refresh your page, and the messages disappear from the UI. They’re still in the database, but we need to pass the messages to the client when a user joins the channel. We could do this by pushing an event to the client after each user joins, but Phoenix provides a 3-tuple join signature to both join the channel and send a join response at the same time.

Let’s update our VideoChannel’s join callback to pass down a list of annotations:

 alias RumblWeb.AnnotationView
 
 def​ join(​"​​videos:"​ <> video_id, _params, socket) ​do
  video_id = String.to_integer(video_id)
  video = Multimedia.get_video!(video_id)
 
  annotations =
  video
  |> Multimedia.list_annotations()
  |> Phoenix.View.render_many(AnnotationView, ​"​​annotation.json"​)
 
  {​:ok​, %{​annotations:​ annotations}, assign(socket, ​:video_id​, video_id)}
 end

Here, we rewrite join to get the video from our Multimedia context. Then, we list the video’s annotations combined with something new. We compose a response by rendering an annotation.json view for every annotation in our list. Instead of building the list by hand, we use Phoenix.View.render_many. The render_many function collects the render results for all elements in the enumerable passed to it. We use the view to present our data, so we offload this work to the view layer so the channel layer can focus on messaging.

Create an AnnotationView in lib/rumbl_web/views/annotation_view.ex to serve as each individual annotation, like this:

 defmodule​ RumblWeb.AnnotationView ​do
 use​ RumblWeb, ​:view
 
 def​ render(​"​​annotation.json"​, %{​annotation:​ annotation}) ​do
  %{
 id:​ annotation.id,
 body:​ annotation.body,
 at:​ annotation.at,
 user:​ render_one(annotation.user, RumblWeb.UserView, ​"​​user.json"​)
  }
 end
 end

Notice the render_one call for the annotation’s user. Phoenix’s view layer neatly embraces functional composition. The render_one function provides conveniences such as handling possible nil results.

Lastly, we return a 3-tuple from join of the form {:ok, response, socket} to pass the response down to the join event. Let’s pick up this response on the client to build the list of messages.

Update your vidChannel.join() callbacks to render a list of annotations received on join:

 vidChannel.join()
  .receive(​"ok"​, ({annotations}) => {
  annotations.forEach( ann => ​this​.renderAnnotation(msgContainer, ann) )
  })
  .receive(​"error"​, reason => console.log(​"join failed"​, reason) )

Refresh your browser and see your history of messages appear immediately!

images/src/channels/persisted-annotations.png

Now that we have our message history on join, we need to schedule the annotations to appear synced up with the video playback. Update video.js, like the following:

  vidChannel.join()
  .receive(​"ok"​, resp => {
 this​.scheduleMessages(msgContainer, resp.annotations)
  })
  .receive(​"error"​, reason => console.log(​"join failed"​, reason) )
 },
 
 renderAnnotation(msgContainer, {user, body, at}){
 let​ template = document.createElement(​"div"​)
  template.innerHTML = ​`
  <a href="#" data-seek="​${​this​.esc(at)}​">
  [​${​this​.formatTime(at)}​]
  <b>​${​this​.esc(user.username)}​</b>: ​${​this​.esc(body)}
  </a>
  `
  msgContainer.appendChild(template)
  msgContainer.scrollTop = msgContainer.scrollHeight
 },
 
 scheduleMessages(msgContainer, annotations){
  clearTimeout(​this​.scheduleTimer)
 this​.schedulerTimer = setTimeout(() => {
 let​ ctime = Player.getCurrentTime()
 let​ remaining = ​this​.renderAtTime(annotations, ctime, msgContainer)
 this​.scheduleMessages(msgContainer, remaining)
  }, 1000)
 },
 
 renderAtTime(annotations, seconds, msgContainer){
 return​ annotations.filter( ann => {
 if​(ann.at > seconds){
 return​ ​true
  } ​else​ {
 this​.renderAnnotation(msgContainer, ann)
 return​ ​false
  }
  })
 },
 
 formatTime(at){
 let​ date = ​new​ Date(​null​)
  date.setSeconds(at / 1000)
 return​ date.toISOString().substr(14, 5)
 },

There’s a lot of code here, but it’s relatively simple. Instead of rendering all annotations immediately on join, we schedule them to render based on the current player time. The scheduleMessages function starts an interval timer that fires every second. Now, each time our timer ticks, we call renderAtTime to find all annotations occurring at or before the current player time.

In renderAtTime, we filter all the messages by time while rendering those that should appear in the timeline. For those yet to appear, we return true to keep a tab on the remaining annotations to filter on the next call. Otherwise, we render the annotation and return false to exclude it from the remaining set.

You can see the end result. We have a second-by-second annotation feed based on the current video playback. Refresh your browser and let’s give it a shot. Try posting a few new annotations at different points, and then refresh. Start playing the video, and then watch your annotations appear synced up with the playback time, as you can see in the screenshot.

images/src/channels/annotation-seek.png

We wired up a data-seek attribute on our renderAnnotation template, but we haven’t done anything with it yet. Let’s support having the annotations clickable so we can jump to the exact time the annotation was made by clicking it. Add this click handler above your vidChannel.join():

 msgContainer.addEventListener(​"click"​, e => {
  e.preventDefault()
 let​ seconds = e.target.getAttribute(​"data-seek"​) ||
  e.target.parentNode.getAttribute(​"data-seek"​)
 if​(!seconds){ ​return​ }
 
  Player.seekTo(seconds)
 })

Now, clicking an annotation will move the player to the time the annotation was made. Cool!

Before we get too excited, we have one more problem to solve. We need to address a critical issue when dealing with disconnects between the client and server.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.226.251.206