From 60382feddc7c57e69a841c51a5e7904c13eb5cc8 Mon Sep 17 00:00:00 2001 From: Chris McCord Date: Thu, 4 Nov 2021 20:49:19 -0400 Subject: [PATCH] Initial synced playback --- assets/js/app.js | 54 +++++++ lib/live_beats/application.ex | 1 + lib/live_beats/id3.ex | 35 ++--- lib/live_beats/media_library.ex | 46 ++++-- lib/live_beats/media_library/song.ex | 11 +- lib/live_beats/mp3_stat.ex | 46 ++++++ lib/live_beats_web/endpoint.ex | 2 +- lib/live_beats_web/live/layout_component.ex | 44 ++++++ lib/live_beats_web/live/live_helpers.ex | 107 +++++++++----- lib/live_beats_web/live/modal_component.ex | 28 ---- lib/live_beats_web/live/player_live.ex | 108 +++++++++++--- .../live/song_live/delete_dialog_component.ex | 47 ------ .../live/song_live/form_component.ex | 102 ------------- .../live/song_live/form_component.html.heex | 131 ----------------- lib/live_beats_web/live/song_live/index.ex | 138 ++++++++++++------ lib/live_beats_web/live/song_live/show.ex | 21 --- .../live/song_live/show.html.heex | 47 ------ .../live/song_live/song_entry_component.ex | 11 +- lib/live_beats_web/live/song_live/song_row.ex | 60 ++++++++ .../live/song_live/upload_form_component.ex | 46 +++++- .../song_live/upload_form_component.html.heex | 12 -- lib/live_beats_web/router.ex | 3 - .../templates/layout/live.html.heex | 2 + mix.lock | 1 + priv/repo/seeds.exs | 2 +- 25 files changed, 563 insertions(+), 542 deletions(-) create mode 100644 lib/live_beats/mp3_stat.ex create mode 100644 lib/live_beats_web/live/layout_component.ex delete mode 100644 lib/live_beats_web/live/modal_component.ex delete mode 100644 lib/live_beats_web/live/song_live/delete_dialog_component.ex delete mode 100644 lib/live_beats_web/live/song_live/form_component.ex delete mode 100644 lib/live_beats_web/live/song_live/form_component.html.heex delete mode 100644 lib/live_beats_web/live/song_live/show.ex delete mode 100644 lib/live_beats_web/live/song_live/show.html.heex create mode 100644 lib/live_beats_web/live/song_live/song_row.ex diff --git a/assets/js/app.js b/assets/js/app.js index d44e804..6cefdf1 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -27,6 +27,58 @@ Hooks.Progress = { } } +Hooks.AudioPlayer = { + mounted(){ + this.playbackBeganAt = null + this.player = this.el.querySelector("audio") + this.currentTime = this.el.querySelector("#player-time") + this.duration = this.el.querySelector("#player-duration") + this.progress = this.el.querySelector("#player-progress") + let enableAudio = () => { + document.removeEventListener("click", enableAudio) + this.player.play().catch(error => null) + this.player.pause() + } + document.addEventListener("click", enableAudio) + this.el.addEventListener("js:play_pause", () => { + this.play() + }) + this.handleEvent("play", ({url, began_at}) => { + this.playbackBeganAt = began_at + this.player.src = url + this.play() + }) + this.handleEvent("pause", () => { + console.log("Server Pause!") + this.pause() + }) + }, + + play(){ + this.player.play().then(() => { + this.player.currentTime = (Date.now() - this.playbackBeganAt) / 1000 + this.progressTimer = setInterval(() => this.updateProgress(), 100) + this.pushEvent("audio-accepted", {}) + }, error => { + this.pushEvent("audio-rejected", {}) + }) + }, + + pause(){ + this.player.pause() + clearInterval(this.progressTimer) + }, + + updateProgress(){ + if(isNaN(this.player.duration)){ return false } + this.progress.style.width = `${(this.player.currentTime / (this.player.duration) * 100)}%` + this.duration.innerText = this.formatTime(this.player.duration) + this.currentTime.innerText = this.formatTime(this.player.currentTime) + }, + + formatTime(seconds){ return new Date(1000 * seconds).toISOString().substr(14, 5) } +} + let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let liveSocket = new LiveSocket("/live", Socket, { hooks: Hooks, @@ -51,6 +103,8 @@ topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", info => topbar.show()) window.addEventListener("phx:page-loading-stop", info => topbar.hide()) +window.addEventListener("js:exec", e => e.target[e.detail.call](...e.detail.args)) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/lib/live_beats/application.ex b/lib/live_beats/application.ex index 2ced1b6..cf7dd3e 100644 --- a/lib/live_beats/application.ex +++ b/lib/live_beats/application.ex @@ -8,6 +8,7 @@ defmodule LiveBeats.Application do @impl true def start(_type, _args) do children = [ + {Task.Supervisor, name: LiveBeats.TaskSupervisor}, # Start the Ecto repository LiveBeats.Repo, # Start the Telemetry supervisor diff --git a/lib/live_beats/id3.ex b/lib/live_beats/id3.ex index d78f99b..9c2b1a6 100644 --- a/lib/live_beats/id3.ex +++ b/lib/live_beats/id3.ex @@ -7,30 +7,17 @@ defmodule LiveBeats.ID3 do year: nil def parse(path) do - binary = File.read!(path) - size = byte_size(binary) - 128 - <<_::binary-size(size), id3_tag::binary>> = binary - - case id3_tag do - << - "TAG", - title::binary-size(30), - artist::binary-size(30), - album::binary-size(30), - year::binary-size(4), - _comment::binary-size(30), - _rest::binary - >> -> - {:ok, - %ID3{ - title: strip(title), - artist: strip(artist), - album: strip(album), - year: year - }} - - _invalid -> - {:error, :invalid} + 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 diff --git a/lib/live_beats/media_library.ex b/lib/live_beats/media_library.ex index 5aaad0d..20ef0be 100644 --- a/lib/live_beats/media_library.ex +++ b/lib/live_beats/media_library.ex @@ -4,21 +4,45 @@ defmodule LiveBeats.MediaLibrary do """ import Ecto.Query, warn: false - alias LiveBeats.Repo - + alias LiveBeats.{Repo, MP3Stat, Accounts} alias LiveBeats.MediaLibrary.{Song, Genre} - def store_mp3(%Song{} = song, tmp_path) do - File.mkdir_p!("priv/static/uploads/songs") - File.cp!(tmp_path, song.mp3_path) + @pubsub LiveBeats.PubSub + + def subscribe(%Accounts.User{} = user) do + Phoenix.PubSub.subscribe(@pubsub, topic(user.id)) end - def import_songs(changesets, consome_file) - when is_map(changesets) and is_function(consome_file, 2) do + def play_song(%Song{id: id}), do: play_song(id) + + def play_song(id) do + song = get_song!(id) + Phoenix.PubSub.broadcast!(@pubsub, topic(song.user_id), {:play, song, %{began_at: now_ms()}}) + end + + def pause_song(%Song{} = song) do + 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)) + end + + def put_stats(%Ecto.Changeset{} = changeset, %MP3Stat{} = stat) do + Ecto.Changeset.put_change(changeset, :duration, stat.duration) + end + + 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) @@ -31,7 +55,7 @@ defmodule LiveBeats.MediaLibrary do results |> Enum.filter(&match?({{:song, _ref}, _}, &1)) |> Enum.map(fn {{:song, ref}, song} -> - consome_file.(ref, fn tmp_path -> store_mp3(song, tmp_path) end) + consume_file.(ref, fn tmp_path -> store_mp3(song, tmp_path) end) {ref, song} end) |> Enum.into(%{})} @@ -58,8 +82,8 @@ defmodule LiveBeats.MediaLibrary do Repo.all(Genre, order_by: [asc: :title]) end - def list_songs do - Repo.all(Song) + def list_songs(limit \\ 100) do + Repo.all(from s in Song, limit: ^limit, order_by: [asc: s.inserted_at]) end def get_song!(id), do: Repo.get!(Song, id) @@ -83,4 +107,6 @@ defmodule LiveBeats.MediaLibrary do 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 636c1ec..1cf59c3 100644 --- a/lib/live_beats/media_library/song.ex +++ b/lib/live_beats/media_library/song.ex @@ -2,6 +2,8 @@ defmodule LiveBeats.MediaLibrary.Song do use Ecto.Schema import Ecto.Changeset + alias LiveBeats.Accounts + schema "songs" do field :album_artist, :string field :artist, :string @@ -11,7 +13,7 @@ defmodule LiveBeats.MediaLibrary.Song do field :title, :string field :mp3_path, :string field :mp3_filename, :string - belongs_to :user, LiveBeats.Accounts.User + belongs_to :user, Accounts.User belongs_to :genre, LiveBeats.MediaLibrary.Genre timestamps() @@ -22,6 +24,11 @@ defmodule LiveBeats.MediaLibrary.Song do song |> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released]) |> validate_required([:artist, :title]) + |> validate_number(:duration, greater_than: 0, less_than: 1200) + end + + def put_user(%Ecto.Changeset{} = changeset, %Accounts.User{} = user) do + put_assoc(changeset, :user, user) end def put_mp3_path(%Ecto.Changeset{} = changeset) do @@ -30,7 +37,7 @@ defmodule LiveBeats.MediaLibrary.Song do changeset |> Ecto.Changeset.put_change(:mp3_filename, filename) - |> Ecto.Changeset.put_change(:mp3_path, "priv/static/uploads/songs/#{filename}") + |> Ecto.Changeset.put_change(:mp3_path, "uploads/songs/#{filename}") else changeset end diff --git a/lib/live_beats/mp3_stat.ex b/lib/live_beats/mp3_stat.ex new file mode 100644 index 0000000..984d946 --- /dev/null +++ b/lib/live_beats/mp3_stat.ex @@ -0,0 +1,46 @@ +defmodule LiveBeats.MP3Stat do + alias LiveBeats.MP3Stat + + defstruct duration: 0, path: nil + + def to_mmss(duration) when is_integer(duration) do + hours = div(duration, 60 * 60) + minutes = div(duration - (hours * 60 * 60), 60) + seconds = rem(duration - (hours * 60 * 60) - (minutes * 60), 60) + + [minutes, seconds] + |> Enum.map(fn count -> String.pad_leading("#{count}", 2, ["0"]) end) + |> Enum.join(":") + end + + def parse(path) do + args = ["-v", "quiet", "-stats", "-i", path, "-f", "null", "-"] + + # "size=N/A time=00:03:00.00 bitrate=N/A speed= 674x" + case System.cmd("ffmpeg", args, stderr_to_stdout: true) do + {output, 0} -> parse_output(output, path) + {_, 1} -> {:error, :bad_file} + other -> {:error, other} + end + end + + defp parse_output(output, path) do + with %{"time" => time} <- Regex.named_captures(~r/.*time=(?