diff --git a/assets/js/app.js b/assets/js/app.js index 6f074e3..6abc8e1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -135,7 +135,7 @@ Hooks.VideoPlayer = { }, }); - window.addEventListener("js:play_video", ({ detail }) => { + const playVideo = ({ detail }) => { const { player } = detail; this.player.options({ techOrder: [player.type === "video/youtube" ? "youtube" : "html5"], @@ -145,7 +145,10 @@ Hooks.VideoPlayer = { this.player.el().parentElement.classList.remove("hidden"); this.player.el().parentElement.classList.add("flex"); this.player.el().scrollIntoView(); - }); + }; + + window.addEventListener("js:play_video", playVideo); + this.handleEvent("js:play_video", playVideo); this.handleEvent("join_chat", Chat.join); }, diff --git a/lib/algora/library.ex b/lib/algora/library.ex index 24ade1a..fbe4ff8 100644 --- a/lib/algora/library.ex +++ b/lib/algora/library.ex @@ -7,9 +7,8 @@ defmodule Algora.Library do import Ecto.Query, warn: false import Ecto.Changeset alias Algora.Accounts.User - alias Algora.Storage - alias Algora.{Repo, Accounts} - alias Algora.Library.{Channel, Video, Events} + alias Algora.{Repo, Accounts, Storage} + alias Algora.Library.{Channel, Video, Events, Subtitle} @pubsub Algora.PubSub @@ -291,4 +290,31 @@ defmodule Algora.Library do defp topic(user_id) when is_integer(user_id), do: "channel:#{user_id}" def topic_livestreams(), do: "livestreams" + + def list_subtitles(%Video{} = video) do + from(s in Subtitle, where: s.video_id == ^video.id, order_by: [asc: s.start]) + |> Repo.replica().all() + end + + def get_subtitle!(id), do: Repo.get!(Subtitle, id) + + def create_subtitle(%Video{} = video, attrs \\ %{}) do + %Subtitle{video_id: video.id} + |> Subtitle.changeset(attrs) + |> Repo.insert() + end + + def update_subtitle(%Subtitle{} = subtitle, attrs) do + subtitle + |> Subtitle.changeset(attrs) + |> Repo.update() + end + + def delete_subtitle(%Subtitle{} = subtitle) do + Repo.delete(subtitle) + end + + def change_subtitle(%Subtitle{} = subtitle, attrs \\ %{}) do + Subtitle.changeset(subtitle, attrs) + end end diff --git a/lib/algora/library/subtitle.ex b/lib/algora/library/subtitle.ex new file mode 100644 index 0000000..e0e62a9 --- /dev/null +++ b/lib/algora/library/subtitle.ex @@ -0,0 +1,21 @@ +defmodule Algora.Library.Subtitle do + alias Algora.Library + use Ecto.Schema + import Ecto.Changeset + + schema "subtitles" do + field :start, :float + field :end, :float + field :body, :string + belongs_to :video, Library.Video + + timestamps() + end + + @doc false + def changeset(subtitle, attrs) do + subtitle + |> cast(attrs, [:body, :start, :end]) + |> validate_required([:body, :start, :end]) + end +end diff --git a/lib/algora_web/live/subtitle_live/form_component.ex b/lib/algora_web/live/subtitle_live/form_component.ex new file mode 100644 index 0000000..82e2efc --- /dev/null +++ b/lib/algora_web/live/subtitle_live/form_component.ex @@ -0,0 +1,92 @@ +defmodule AlgoraWeb.SubtitleLive.FormComponent do + use AlgoraWeb, :live_component + + alias Algora.Library + + @impl true + def render(assigns) do + ~H""" +
+ <.header class="pb-6"> + <%= @title %> + <:subtitle>Use this form to manage subtitle records in your database. + + + <.simple_form + for={@form} + id="subtitle-form" + phx-target={@myself} + phx-change="validate" + phx-submit="save" + > + <.input field={@form[:body]} type="text" label="Body" /> + <.input field={@form[:start]} type="number" label="Start" step="any" /> + <.input field={@form[:end]} type="number" label="End" step="any" /> + <:actions> + <.button phx-disable-with="Saving...">Save Subtitle + + +
+ """ + end + + @impl true + def update(%{subtitle: subtitle} = assigns, socket) do + changeset = Library.change_subtitle(subtitle) + + {:ok, + socket + |> assign(assigns) + |> assign_form(changeset)} + end + + @impl true + def handle_event("validate", %{"subtitle" => subtitle_params}, socket) do + changeset = + socket.assigns.subtitle + |> Library.change_subtitle(subtitle_params) + |> Map.put(:action, :validate) + + {:noreply, assign_form(socket, changeset)} + end + + def handle_event("save", %{"subtitle" => subtitle_params}, socket) do + save_subtitle(socket, socket.assigns.action, subtitle_params) + end + + defp save_subtitle(socket, :edit, subtitle_params) do + case Library.update_subtitle(socket.assigns.subtitle, subtitle_params) do + {:ok, subtitle} -> + notify_parent({:saved, subtitle}) + + {:noreply, + socket + |> put_flash(:info, "Subtitle updated successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp save_subtitle(socket, :new, subtitle_params) do + case Library.create_subtitle(socket.assigns.video, subtitle_params) do + {:ok, subtitle} -> + notify_parent({:saved, subtitle}) + + {:noreply, + socket + |> put_flash(:info, "Subtitle created successfully") + |> push_patch(to: socket.assigns.patch)} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign_form(socket, changeset)} + end + end + + defp assign_form(socket, %Ecto.Changeset{} = changeset) do + assign(socket, :form, to_form(changeset)) + end + + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) +end diff --git a/lib/algora_web/live/subtitle_live/index.ex b/lib/algora_web/live/subtitle_live/index.ex new file mode 100644 index 0000000..cd6be29 --- /dev/null +++ b/lib/algora_web/live/subtitle_live/index.ex @@ -0,0 +1,65 @@ +defmodule AlgoraWeb.SubtitleLive.Index do + use AlgoraWeb, :live_view + + alias Algora.Library + alias Algora.Library.Subtitle + + @impl true + def mount(%{"video_id" => video_id}, _session, socket) do + video = Library.get_video!(video_id) + + if connected?(socket), do: send(self(), :play_video) + + {:ok, + socket + |> assign(:video, video) + |> stream(:subtitles, Library.list_subtitles(video))} + end + + @impl true + def handle_params(params, _url, socket) do + {:noreply, apply_action(socket, socket.assigns.live_action, params)} + end + + defp apply_action(socket, :edit, %{"id" => id}) do + socket + |> assign(:page_title, "Edit Subtitle") + |> assign(:subtitle, Library.get_subtitle!(id)) + end + + defp apply_action(socket, :new, _params) do + socket + |> assign(:page_title, "New Subtitle") + |> assign(:subtitle, %Subtitle{}) + end + + defp apply_action(socket, :index, _params) do + socket + |> assign(:page_title, "Listing Subtitles") + |> assign(:subtitle, nil) + end + + @impl true + def handle_info(:play_video, socket) do + video = socket.assigns.video + + {:noreply, + socket + |> push_event("js:play_video", %{ + detail: %{player: %{src: video.url, type: Library.player_type(video)}} + })} + end + + @impl true + def handle_info({AlgoraWeb.SubtitleLive.FormComponent, {:saved, subtitle}}, socket) do + {:noreply, stream_insert(socket, :subtitles, subtitle)} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + subtitle = Library.get_subtitle!(id) + {:ok, _} = Library.delete_subtitle(subtitle) + + {:noreply, stream_delete(socket, :subtitles, subtitle)} + end +end diff --git a/lib/algora_web/live/subtitle_live/index.html.heex b/lib/algora_web/live/subtitle_live/index.html.heex new file mode 100644 index 0000000..aa31079 --- /dev/null +++ b/lib/algora_web/live/subtitle_live/index.html.heex @@ -0,0 +1,51 @@ +<.header class="p-4 sm:p-6 lg:p-8"> + Subtitles + <:actions> + <.link patch={~p"/videos/#{@video.id}/subtitles/new"}> + <.button>New Subtitle + + + + +<.table + id="subtitles" + rows={@streams.subtitles} + row_click={ + fn {_id, subtitle} -> JS.navigate(~p"/videos/#{@video.id}/subtitles/#{subtitle}") end + } +> + <:col :let={{_id, subtitle}} label="Start"><%= subtitle.start %> + <:col :let={{_id, subtitle}} label="End"><%= subtitle.end %> + <:col :let={{_id, subtitle}} label="Body"><%= subtitle.body %> + <:action :let={{_id, subtitle}}> +
+ <.link navigate={~p"/videos/#{@video.id}/subtitles/#{subtitle}"}>Show +
+ <.link patch={~p"/videos/#{@video.id}/subtitles/#{subtitle}/edit"}>Edit + + <:action :let={{id, subtitle}}> + <.link + phx-click={JS.push("delete", value: %{id: subtitle.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + + +<.modal + :if={@live_action in [:new, :edit]} + id="subtitle-modal" + show + on_cancel={JS.navigate(~p"/videos/#{@video.id}/subtitles")} +> + <.live_component + module={AlgoraWeb.SubtitleLive.FormComponent} + id={@subtitle.id || :new} + title={@page_title} + action={@live_action} + subtitle={@subtitle} + video={@video} + patch={~p"/videos/#{@video.id}/subtitles"} + /> + diff --git a/lib/algora_web/live/subtitle_live/show.ex b/lib/algora_web/live/subtitle_live/show.ex new file mode 100644 index 0000000..8151662 --- /dev/null +++ b/lib/algora_web/live/subtitle_live/show.ex @@ -0,0 +1,36 @@ +defmodule AlgoraWeb.SubtitleLive.Show do + use AlgoraWeb, :live_view + + alias Algora.Library + + @impl true + def mount(%{"video_id" => video_id}, _session, socket) do + video = Library.get_video!(video_id) + + if connected?(socket), do: send(self(), :play_video) + + {:ok, socket |> assign(:video, video)} + end + + @impl true + def handle_info(:play_video, socket) do + video = socket.assigns.video + + {:noreply, + socket + |> push_event("js:play_video", %{ + detail: %{player: %{src: video.url, type: Library.player_type(video)}} + })} + end + + @impl true + def handle_params(%{"id" => id}, _, socket) do + {:noreply, + socket + |> assign(:page_title, page_title(socket.assigns.live_action)) + |> assign(:subtitle, Library.get_subtitle!(id))} + end + + defp page_title(:show), do: "Show Subtitle" + defp page_title(:edit), do: "Edit Subtitle" +end diff --git a/lib/algora_web/live/subtitle_live/show.html.heex b/lib/algora_web/live/subtitle_live/show.html.heex new file mode 100644 index 0000000..715cc13 --- /dev/null +++ b/lib/algora_web/live/subtitle_live/show.html.heex @@ -0,0 +1,38 @@ +
+ <.header class="py-4 sm:py-6 lg:py-8"> + Subtitle <%= @subtitle.id %> + <:subtitle>This is a subtitle record from your database. + <:actions> + <.link + patch={~p"/videos/#{@video.id}/subtitles/#{@subtitle}/show/edit"} + phx-click={JS.push_focus()} + > + <.button>Edit subtitle + + + + + <.list> + <:item title="Body"><%= @subtitle.body %> + <:item title="Start"><%= @subtitle.start %> + <:item title="End"><%= @subtitle.end %> + + + <.back navigate={~p"/videos/#{@video.id}/subtitles"}>Back to subtitles + + <.modal + :if={@live_action == :edit} + id="subtitle-modal" + show + on_cancel={JS.patch(~p"/videos/#{@video.id}/subtitles/#{@subtitle}")} + > + <.live_component + module={AlgoraWeb.SubtitleLive.FormComponent} + id={@subtitle.id} + title={@page_title} + action={@live_action} + subtitle={@subtitle} + patch={~p"/videos/#{@video.id}/subtitles/#{@subtitle}"} + /> + +
diff --git a/lib/algora_web/router.ex b/lib/algora_web/router.ex index d4546e6..14b759c 100644 --- a/lib/algora_web/router.ex +++ b/lib/algora_web/router.ex @@ -50,6 +50,12 @@ defmodule AlgoraWeb.Router do on_mount: [{AlgoraWeb.UserAuth, :ensure_authenticated}, AlgoraWeb.Nav] do live "/channel/settings", SettingsLive, :edit live "/:channel_handle/stream", ChannelLive, :stream + + live "/videos/:video_id/subtitles", SubtitleLive.Index, :index + live "/videos/:video_id/subtitles/new", SubtitleLive.Index, :new + live "/videos/:video_id/subtitles/:id", SubtitleLive.Show, :show + live "/videos/:video_id/subtitles/:id/edit", SubtitleLive.Index, :edit + live "/videos/:video_id/subtitles/:id/show/edit", SubtitleLive.Show, :edit end live_session :default, on_mount: [{AlgoraWeb.UserAuth, :current_user}, AlgoraWeb.Nav] do diff --git a/priv/repo/migrations/20240304213317_create_subtitles.exs b/priv/repo/migrations/20240304213317_create_subtitles.exs new file mode 100644 index 0000000..a2f1e49 --- /dev/null +++ b/priv/repo/migrations/20240304213317_create_subtitles.exs @@ -0,0 +1,16 @@ +defmodule Algora.Repo.Migrations.CreateSubtitles do + use Ecto.Migration + + def change do + create table(:subtitles) do + add :body, :text + add :start, :float + add :end, :float + add :video_id, references(:videos, on_delete: :nothing), null: false + + timestamps() + end + + create index(:subtitles, [:video_id]) + end +end