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!
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.
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.
18.226.251.206