diff --git a/assets/js/app.js b/assets/js/app.js index 6cefdf1..c7ab771 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -3,15 +3,7 @@ import {Socket} from "phoenix" import {LiveSocket} from "./phoenix_live_view" import topbar from "../vendor/topbar" -let render = (webComponent, html) => { - let shadow = webComponent.attachShadow({mode: "open"}) - document.querySelectorAll("link").forEach(link => shadow.appendChild(link.cloneNode())) - let div = document.createElement("div") - div.setAttribute("class", webComponent.getAttribute("class")) - div.innerHTML = html || webComponent.innerHTML - shadow.appendChild(div) - return div -} +let nowSeconds = () => Math.round(Date.now() / 1000) let Hooks = {} @@ -40,23 +32,30 @@ Hooks.AudioPlayer = { this.player.pause() } document.addEventListener("click", enableAudio) + this.el.addEventListener("js:listen_now", () => this.play({sync: true})) this.el.addEventListener("js:play_pause", () => { - this.play() + if(this.player.paused){ + this.play() + } }) - this.handleEvent("play", ({url, began_at}) => { - this.playbackBeganAt = began_at - this.player.src = url - this.play() + this.handleEvent("play", ({url, elapsed}) => { + this.playbackBeganAt = nowSeconds() - elapsed + if(this.player.src === url && this.player.paused){ + this.play({sync: true}) + } else if(this.player.src !== url) { + this.player.src = url + this.play({sync: true}) + } }) this.handleEvent("pause", () => { - console.log("Server Pause!") this.pause() }) }, - play(){ + play(opts = {}){ + let {sync} = opts this.player.play().then(() => { - this.player.currentTime = (Date.now() - this.playbackBeganAt) / 1000 + if(sync){ this.player.currentTime = nowSeconds() - this.playbackBeganAt } this.progressTimer = setInterval(() => this.updateProgress(), 100) this.pushEvent("audio-accepted", {}) }, error => { @@ -65,8 +64,8 @@ Hooks.AudioPlayer = { }, pause(){ - this.player.pause() clearInterval(this.progressTimer) + this.player.pause() }, updateProgress(){ diff --git a/lib/live_beats/id3.ex b/lib/live_beats/id3.ex deleted file mode 100644 index 9c2b1a6..0000000 --- a/lib/live_beats/id3.ex +++ /dev/null @@ -1,25 +0,0 @@ -defmodule LiveBeats.ID3 do - alias LiveBeats.ID3 - - defstruct title: nil, - artist: nil, - album: nil, - year: nil - - def parse(path) do - with {:ok, parsed} <- :id3_tag_reader.read_tag(path) do - {:ok, parsed} - # %ID3{ - # title: strip(title), - # artist: strip(artist), - # album: strip(album), - # year: 2028 - # }} - else - other -> - {:error, other} - end - end - - defp strip(binary), do: String.trim_trailing(binary, <<0>>) -end diff --git a/lib/live_beats/media_library.ex b/lib/live_beats/media_library.ex index 20ef0be..aba6671 100644 --- a/lib/live_beats/media_library.ex +++ b/lib/live_beats/media_library.ex @@ -3,12 +3,18 @@ defmodule LiveBeats.MediaLibrary do The MediaLibrary context. """ + require Logger import Ecto.Query, warn: false alias LiveBeats.{Repo, MP3Stat, Accounts} alias LiveBeats.MediaLibrary.{Song, Genre} + alias Ecto.{Multi, Changeset} @pubsub LiveBeats.PubSub + defdelegate stopped?(song), to: Song + defdelegate playing?(song), to: Song + defdelegate paused?(song), to: Song + def subscribe(%Accounts.User{} = user) do Phoenix.PubSub.subscribe(@pubsub, topic(user.id)) end @@ -17,19 +23,65 @@ defmodule LiveBeats.MediaLibrary do def play_song(id) do song = get_song!(id) - Phoenix.PubSub.broadcast!(@pubsub, topic(song.user_id), {:play, song, %{began_at: now_ms()}}) + + played_at = + cond do + playing?(song) -> + song.played_at + + paused?(song) -> + elapsed = DateTime.diff(song.paused_at, song.played_at, :second) + DateTime.add(DateTime.utc_now(), -elapsed) + + true -> + DateTime.utc_now() + end + + changeset = + Changeset.change(song, %{ + played_at: DateTime.truncate(played_at, :second), + status: :playing + }) + + stopped_query = + from s in Song, + where: s.user_id == ^song.user_id and s.status == :playing, + update: [set: [status: :stopped]] + + {:ok, %{now_playing: new_song}} = + Multi.new() + |> Multi.update_all(:now_stopped, fn _ -> stopped_query end, []) + |> Multi.update(:now_playing, changeset) + |> Repo.transaction() + + elapsed = elapsed_playback(new_song) + Phoenix.PubSub.broadcast!(@pubsub, topic(song.user_id), {:play, song, %{elapsed: elapsed}}) end def pause_song(%Song{} = song) do + now = DateTime.truncate(DateTime.utc_now(), :second) + set = [status: :paused, paused_at: now] + pause_query = from(s in Song, where: s.id == ^song.id, update: [set: ^set]) + + stopped_query = + from s in Song, + where: s.user_id == ^song.user_id and s.status in [:playing, :paused], + update: [set: [status: :stopped]] + + {:ok, _} = + Multi.new() + |> Multi.update_all(:now_stopped, fn _ -> stopped_query end, []) + |> Multi.update_all(:now_paused, fn _ -> pause_query end, []) + |> Repo.transaction() + Phoenix.PubSub.broadcast!(@pubsub, topic(song.user_id), {:pause, song}) end defp topic(user_id), do: "room:#{user_id}" def store_mp3(%Song{} = song, tmp_path) do - dir = "priv/static/uploads/songs" - File.mkdir_p!(dir) - File.cp!(tmp_path, Path.join(dir, song.mp3_filename)) + File.mkdir_p!(Path.dirname(song.mp3_filepath)) + File.cp!(tmp_path, song.mp3_filepath) end def put_stats(%Ecto.Changeset{} = changeset, %MP3Stat{} = stat) do @@ -38,18 +90,17 @@ defmodule LiveBeats.MediaLibrary do def import_songs(%Accounts.User{} = user, changesets, consume_file) when is_map(changesets) and is_function(consume_file, 2) do - changesets - |> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc -> - chset = - chset - |> Song.put_user(user) - |> Song.put_mp3_path() - |> Map.put(:action, nil) + multi = + Enum.reduce(changesets, Ecto.Multi.new(), fn {ref, chset}, acc -> + chset = + chset + |> Song.put_user(user) + |> Song.put_mp3_path() - Ecto.Multi.insert(acc, {:song, ref}, chset) - end) - |> LiveBeats.Repo.transaction() - |> case do + Ecto.Multi.insert(acc, {:song, ref}, chset) + end) + + case LiveBeats.Repo.transaction(multi) do {:ok, results} -> {:ok, results @@ -83,7 +134,25 @@ defmodule LiveBeats.MediaLibrary do end def list_songs(limit \\ 100) do - Repo.all(from s in Song, limit: ^limit, order_by: [asc: s.inserted_at]) + Repo.all(from s in Song, limit: ^limit, order_by: [asc: s.inserted_at, asc: s.id]) + end + + def get_current_active_song(user_id) do + Repo.one(from s in Song, where: s.user_id == ^user_id and s.status in [:playing, :paused]) + end + + def elapsed_playback(%Song{} = song) do + cond do + playing?(song) -> + start_seconds = song.played_at |> DateTime.to_unix() + System.os_time(:second) - start_seconds + + paused?(song) -> + DateTime.diff(song.paused_at, song.played_at, :second) + + stopped?(song) -> + 0 + end end def get_song!(id), do: Repo.get!(Song, id) @@ -101,12 +170,20 @@ defmodule LiveBeats.MediaLibrary do end def delete_song(%Song{} = song) do + case File.rm(song.mp3_filepath) do + :ok -> + :ok + + {:error, reason} -> + Logger.info( + "unable to delete song #{song.id} at #{song.mp3_filepath}, got: #{inspect(reason)}" + ) + end + Repo.delete(song) end def change_song(%Song{} = song, attrs \\ %{}) do Song.changeset(song, attrs) end - - defp now_ms, do: System.system_time() |> System.convert_time_unit(:native, :millisecond) end diff --git a/lib/live_beats/media_library/song.ex b/lib/live_beats/media_library/song.ex index 1cf59c3..3859d4c 100644 --- a/lib/live_beats/media_library/song.ex +++ b/lib/live_beats/media_library/song.ex @@ -2,23 +2,31 @@ defmodule LiveBeats.MediaLibrary.Song do use Ecto.Schema import Ecto.Changeset + alias LiveBeats.MediaLibrary.Song alias LiveBeats.Accounts schema "songs" do field :album_artist, :string field :artist, :string + field :played_at, :utc_datetime + field :paused_at, :utc_datetime field :date_recorded, :naive_datetime field :date_released, :naive_datetime field :duration, :integer + field :status, Ecto.Enum, values: [stopped: 1, playing: 2, paused: 3] field :title, :string field :mp3_path, :string - field :mp3_filename, :string + field :mp3_filepath, :string belongs_to :user, Accounts.User belongs_to :genre, LiveBeats.MediaLibrary.Genre timestamps() end + def playing?(%Song{} = song), do: song.status == :playing + def paused?(%Song{} = song), do: song.status == :paused + def stopped?(%Song{} = song), do: song.status == :stopped + @doc false def changeset(song, attrs) do song @@ -34,10 +42,11 @@ defmodule LiveBeats.MediaLibrary.Song do def put_mp3_path(%Ecto.Changeset{} = changeset) do if changeset.valid? do filename = Ecto.UUID.generate() <> ".mp3" + filepath = Path.join("priv/static/uploads/songs", filename) changeset - |> Ecto.Changeset.put_change(:mp3_filename, filename) - |> Ecto.Changeset.put_change(:mp3_path, "uploads/songs/#{filename}") + |> Ecto.Changeset.put_change(:mp3_filepath, filepath) + |> Ecto.Changeset.put_change(:mp3_path, Path.join("uploads/songs", filename)) else changeset end diff --git a/lib/live_beats_web/live/player_live.ex b/lib/live_beats_web/live/player_live.ex index 87fd56e..b8e03d9 100644 --- a/lib/live_beats_web/live/player_live.ex +++ b/lib/live_beats_web/live/player_live.ex @@ -87,7 +87,7 @@ defmodule LiveBeatsWeb.PlayerLive do <%= if @error do %> - <.modal show id="enable-audio" on_confirm={js_play_pause() |> hide_modal("enable-audio")}> + <.modal show id="enable-audio" on_confirm={js_listen_now() |> hide_modal("enable-audio")}> <:title>Start Listening now Your browser needs a click event to enable playback <:confirm>Listen Now @@ -101,16 +101,25 @@ defmodule LiveBeatsWeb.PlayerLive do def mount(_parmas, _session, socket) do if connected?(socket) and socket.assigns.current_user do MediaLibrary.subscribe(socket.assigns.current_user) + send(self(), :play_current) end - {:ok, assign(socket, song: nil, playing: false, error: false), layout: false} + socket = + assign(socket, + song: nil, + playing: false, + error: false, + current_user_id: socket.assigns.current_user.id, + # todo use actual room user id + room_user_id: socket.assigns.current_user.id + ) + + {:ok, socket, layout: false, temporary_assigns: [current_user: nil]} end def handle_event("play_pause", _, socket) do %{song: song, playing: playing} = socket.assigns - IO.inspect({:play_pause, playing}) - cond do song && playing -> MediaLibrary.pause_song(song) @@ -133,6 +142,15 @@ defmodule LiveBeatsWeb.PlayerLive do {:noreply, assign(socket, error: false)} end + def handle_info(:play_current, socket) do + # we raced a pubsub, noop + if socket.assigns.song do + {:noreply, socket} + else + {:noreply, play_current_song(socket)} + end + end + def handle_info({:pause, _}, socket) do {:noreply, socket @@ -140,14 +158,44 @@ defmodule LiveBeatsWeb.PlayerLive do |> assign(playing: false)} end - def handle_info({:play, %Song{} = song, %{began_at: at}}, socket) do - {:noreply, - socket - |> push_event("play", %{began_at: at, url: Path.join(LiveBeatsWeb.Endpoint.url(), song.mp3_path)}) - |> assign(song: song, playing: true)} + def handle_info({:play, %Song{} = song, %{elapsed: elapsed}}, socket) do + {:noreply, play_song(socket, song, elapsed)} end - defp js_play_pause(js \\ %JS{}) do + defp play_song(socket, %Song{} = song, elapsed) do + socket + |> push_play(song, elapsed) + |> assign(song: song, playing: true) + end + + defp js_play_pause(%JS{} = js) do JS.dispatch(js, "js:play_pause", to: "#audio-player") end + + defp js_listen_now(js \\ %JS{}) do + JS.dispatch(js, "js:listen_now", to: "#audio-player") + end + + defp play_current_song(socket) do + song = MediaLibrary.get_current_active_song(socket.assigns.room_user_id) + + cond do + song && MediaLibrary.playing?(song) -> + play_song(socket, song, MediaLibrary.elapsed_playback(song)) + + song && MediaLibrary.paused?(song) -> + assign(socket, song: song, playing: false) + + true -> + socket + end + end + + defp push_play(socket, %Song{} = song, elapsed) do + push_event(socket, "play", %{ + paused: Song.paused?(song), + elapsed: elapsed, + url: Path.join(LiveBeatsWeb.Endpoint.url(), song.mp3_path) + }) + end end diff --git a/lib/live_beats_web/live/song_live/index.ex b/lib/live_beats_web/live/song_live/index.ex index c2ee5c8..a7ddad8 100644 --- a/lib/live_beats_web/live/song_live/index.ex +++ b/lib/live_beats_web/live/song_live/index.ex @@ -2,8 +2,8 @@ defmodule LiveBeatsWeb.SongLive.Index do use LiveBeatsWeb, :live_view alias LiveBeats.{MediaLibrary, MP3Stat} - alias LiveBeats.MediaLibrary.Song alias LiveBeatsWeb.LayoutComponent + alias LiveBeatsWeb.SongLive.{SongRowComponent, UploadFormComponent} def render(assigns) do ~H""" @@ -27,7 +27,7 @@ defmodule LiveBeatsWeb.SongLive.Index do <% end %> <.live_table - module={LiveBeatsWeb.SongLive.SongRow} + module={SongRowComponent} rows={@songs} row_id={fn song -> "song-#{song.id}" end} > @@ -45,19 +45,33 @@ defmodule LiveBeatsWeb.SongLive.Index do end def mount(_params, _session, socket) do + %{current_user: current_user} = socket.assigns + if connected?(socket) do - MediaLibrary.subscribe(socket.assigns.current_user) + MediaLibrary.subscribe(current_user) end - {:ok, assign(socket, songs: list_songs(), active_id: nil), temporary_assigns: [songs: []]} + active_id = + if song = MediaLibrary.get_current_active_song(current_user.id) do + SongRowComponent.send_status(song.id, song.status) + song.id + end + + {:ok, assign(socket, songs: list_songs(), active_id: active_id), temporary_assigns: [songs: []]} end def handle_params(params, _url, socket) do {:noreply, socket |> apply_action(socket.assigns.live_action, params) |> maybe_show_modal()} end - def handle_event("play-song", %{"id" => id}, socket) do - MediaLibrary.play_song(id) + def handle_event("play_or_pause", %{"id" => id}, socket) do + song = MediaLibrary.get_song!(id) + if socket.assigns.active_id == id and MediaLibrary.playing?(song) do + MediaLibrary.pause_song(song) + else + MediaLibrary.play_song(id) + end + {:noreply, socket} end @@ -67,42 +81,59 @@ defmodule LiveBeatsWeb.SongLive.Index do {:noreply, socket} end - def handle_info({:play, %Song{} = song, _meta}, socket) do + def handle_info({:play, %MediaLibrary.Song{} = song, _meta}, socket) do {:noreply, play_song(socket, song)} end - def handle_info({:pause, %Song{} = song}, socket) do + def handle_info({:pause, %MediaLibrary.Song{} = song}, socket) do {:noreply, pause_song(socket, song.id)} end + defp stop_song(socket, song_id) do + SongRowComponent.send_status(song_id, :stopped) + + if socket.assigns.active_id == song_id do + assign(socket, :active_id, nil) + else + socket + end + end + defp pause_song(socket, song_id) do - send_update(LiveBeatsWeb.SongLive.SongRow, id: "song-#{song_id}", action: :deactivate) + SongRowComponent.send_status(song_id, :paused) socket end - defp play_song(socket, %Song{} = song) do - send_update(LiveBeatsWeb.SongLive.SongRow, id: "song-#{song.id}", action: :activate) + defp play_song(socket, %MediaLibrary.Song{} = song) do + %{active_id: active_id} = socket.assigns - if socket.assigns.active_id do - socket - |> pause_song(socket.assigns.active_id) - |> assign(active_id: song.id) - else - assign(socket, active_id: song.id) + cond do + active_id == song.id -> + SongRowComponent.send_status(song.id, :playing) + socket + + active_id -> + SongRowComponent.send_status(song.id, :playing) + + socket + |> stop_song(active_id) + |> assign(active_id: song.id) + + true -> + SongRowComponent.send_status(song.id, :playing) + assign(socket, active_id: song.id) end end defp maybe_show_modal(socket) do - if socket.assigns.live_action in [:new, :edit] do - LayoutComponent.show_modal(LiveBeatsWeb.SongLive.UploadFormComponent, %{ + if socket.assigns.live_action in [:new] do + LayoutComponent.show_modal(UploadFormComponent, %{ + id: :new, confirm: {"Save", type: "submit", form: "song-form"}, patch_to: Routes.song_index_path(socket, :index), - id: socket.assigns.song.id || :new, - title: socket.assigns.page_title, - action: socket.assigns.live_action, song: socket.assigns.song, - current_user: socket.assigns.current_user, - genres: socket.assigns.genres + title: socket.assigns.page_title, + current_user: socket.assigns.current_user }) else LayoutComponent.hide_modal() @@ -114,7 +145,7 @@ defmodule LiveBeatsWeb.SongLive.Index do defp apply_action(socket, :new, _params) do socket |> assign(:page_title, "Add Songs") - |> assign(:song, %Song{}) + |> assign(:song, %MediaLibrary.Song{}) end defp apply_action(socket, :index, _params) do diff --git a/lib/live_beats_web/live/song_live/song_entry_component.ex b/lib/live_beats_web/live/song_live/song_entry.ex similarity index 97% rename from lib/live_beats_web/live/song_live/song_entry_component.ex rename to lib/live_beats_web/live/song_live/song_entry.ex index fa2ae22..a737f89 100644 --- a/lib/live_beats_web/live/song_live/song_entry_component.ex +++ b/lib/live_beats_web/live/song_live/song_entry.ex @@ -1,4 +1,4 @@ -defmodule LiveBeatsWeb.SongLive.SongEntryComponent do +defmodule LiveBeatsWeb.SongLive.SongEntry do use LiveBeatsWeb, :live_component alias LiveBeats.MP3Stat diff --git a/lib/live_beats_web/live/song_live/song_row.ex b/lib/live_beats_web/live/song_live/song_row_component.ex similarity index 59% rename from lib/live_beats_web/live/song_live/song_row.ex rename to lib/live_beats_web/live/song_live/song_row_component.ex index 222385c..6494e89 100644 --- a/lib/live_beats_web/live/song_live/song_row.ex +++ b/lib/live_beats_web/live/song_live/song_row_component.ex @@ -1,22 +1,32 @@ -defmodule LiveBeatsWeb.SongLive.SongRow do +defmodule LiveBeatsWeb.SongLive.SongRowComponent do use LiveBeatsWeb, :live_component + def send_status(id, status) when status in [:playing, :paused, :stopped] do + send_update(__MODULE__, id: "song-#{id}", action: :send, status: status) + end + def render(assigns) do ~H"""