Initial synced playback

This commit is contained in:
Chris McCord 2021-11-04 20:49:19 -04:00
parent aaa89c7c76
commit 60382feddc
25 changed files with 563 additions and 542 deletions

View file

@ -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 csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
hooks: Hooks, 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-start", info => topbar.show())
window.addEventListener("phx:page-loading-stop", info => topbar.hide()) 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 // connect if there are any LiveViews on the page
liveSocket.connect() liveSocket.connect()

View file

@ -8,6 +8,7 @@ defmodule LiveBeats.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children = [
{Task.Supervisor, name: LiveBeats.TaskSupervisor},
# Start the Ecto repository # Start the Ecto repository
LiveBeats.Repo, LiveBeats.Repo,
# Start the Telemetry supervisor # Start the Telemetry supervisor

View file

@ -7,30 +7,17 @@ defmodule LiveBeats.ID3 do
year: nil year: nil
def parse(path) do def parse(path) do
binary = File.read!(path) with {:ok, parsed} <- :id3_tag_reader.read_tag(path) do
size = byte_size(binary) - 128 {:ok, parsed}
<<_::binary-size(size), id3_tag::binary>> = binary # %ID3{
# title: strip(title),
case id3_tag do # artist: strip(artist),
<< # album: strip(album),
"TAG", # year: 2028
title::binary-size(30), # }}
artist::binary-size(30), else
album::binary-size(30), other ->
year::binary-size(4), {:error, other}
_comment::binary-size(30),
_rest::binary
>> ->
{:ok,
%ID3{
title: strip(title),
artist: strip(artist),
album: strip(album),
year: year
}}
_invalid ->
{:error, :invalid}
end end
end end

View file

@ -4,21 +4,45 @@ defmodule LiveBeats.MediaLibrary do
""" """
import Ecto.Query, warn: false import Ecto.Query, warn: false
alias LiveBeats.Repo alias LiveBeats.{Repo, MP3Stat, Accounts}
alias LiveBeats.MediaLibrary.{Song, Genre} alias LiveBeats.MediaLibrary.{Song, Genre}
def store_mp3(%Song{} = song, tmp_path) do @pubsub LiveBeats.PubSub
File.mkdir_p!("priv/static/uploads/songs")
File.cp!(tmp_path, song.mp3_path) def subscribe(%Accounts.User{} = user) do
Phoenix.PubSub.subscribe(@pubsub, topic(user.id))
end end
def import_songs(changesets, consome_file) def play_song(%Song{id: id}), do: play_song(id)
when is_map(changesets) and is_function(consome_file, 2) do
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 changesets
|> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc -> |> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc ->
chset = chset =
chset chset
|> Song.put_user(user)
|> Song.put_mp3_path() |> Song.put_mp3_path()
|> Map.put(:action, nil) |> Map.put(:action, nil)
@ -31,7 +55,7 @@ defmodule LiveBeats.MediaLibrary do
results results
|> Enum.filter(&match?({{:song, _ref}, _}, &1)) |> Enum.filter(&match?({{:song, _ref}, _}, &1))
|> Enum.map(fn {{:song, ref}, song} -> |> 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} {ref, song}
end) end)
|> Enum.into(%{})} |> Enum.into(%{})}
@ -58,8 +82,8 @@ defmodule LiveBeats.MediaLibrary do
Repo.all(Genre, order_by: [asc: :title]) Repo.all(Genre, order_by: [asc: :title])
end end
def list_songs do def list_songs(limit \\ 100) do
Repo.all(Song) Repo.all(from s in Song, limit: ^limit, order_by: [asc: s.inserted_at])
end end
def get_song!(id), do: Repo.get!(Song, id) def get_song!(id), do: Repo.get!(Song, id)
@ -83,4 +107,6 @@ defmodule LiveBeats.MediaLibrary do
def change_song(%Song{} = song, attrs \\ %{}) do def change_song(%Song{} = song, attrs \\ %{}) do
Song.changeset(song, attrs) Song.changeset(song, attrs)
end end
defp now_ms, do: System.system_time() |> System.convert_time_unit(:native, :millisecond)
end end

View file

@ -2,6 +2,8 @@ defmodule LiveBeats.MediaLibrary.Song do
use Ecto.Schema use Ecto.Schema
import Ecto.Changeset import Ecto.Changeset
alias LiveBeats.Accounts
schema "songs" do schema "songs" do
field :album_artist, :string field :album_artist, :string
field :artist, :string field :artist, :string
@ -11,7 +13,7 @@ defmodule LiveBeats.MediaLibrary.Song do
field :title, :string field :title, :string
field :mp3_path, :string field :mp3_path, :string
field :mp3_filename, :string field :mp3_filename, :string
belongs_to :user, LiveBeats.Accounts.User belongs_to :user, Accounts.User
belongs_to :genre, LiveBeats.MediaLibrary.Genre belongs_to :genre, LiveBeats.MediaLibrary.Genre
timestamps() timestamps()
@ -22,6 +24,11 @@ defmodule LiveBeats.MediaLibrary.Song do
song song
|> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released]) |> cast(attrs, [:album_artist, :artist, :title, :date_recorded, :date_released])
|> validate_required([:artist, :title]) |> 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 end
def put_mp3_path(%Ecto.Changeset{} = changeset) do def put_mp3_path(%Ecto.Changeset{} = changeset) do
@ -30,7 +37,7 @@ defmodule LiveBeats.MediaLibrary.Song do
changeset changeset
|> Ecto.Changeset.put_change(:mp3_filename, filename) |> 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 else
changeset changeset
end end

View file

@ -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=(?<time>[^\s]+).*/, output),
[hours, minutes, seconds, _milliseconds] <- ints(String.split(time, [":", "."])) do
duration = hours * 60 * 60 + minutes * 60 + seconds
{:ok, %MP3Stat{duration: duration, path: path}}
else
_ -> {:error, :bad_duration}
end
end
defp ints(strings) when is_list(strings) do
Enum.flat_map(strings, fn str ->
case Integer.parse(str) do
{int, ""} -> [int]
{_, _} -> []
:error -> []
end
end)
end
end

View file

@ -20,7 +20,7 @@ defmodule LiveBeatsWeb.Endpoint do
at: "/", at: "/",
from: :live_beats, from: :live_beats,
gzip: false, gzip: false,
only: ~w(assets fonts images favicon.ico robots.txt) only: ~w(assets fonts images favicon.ico robots.txt uploads)
# Code reloading can be explicitly enabled under the # Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint. # :code_reloader configuration of your endpoint.

View file

@ -0,0 +1,44 @@
defmodule LiveBeatsWeb.LayoutComponent do
@moduledoc """
Component for rendering content inside layout without full DOM patch.
"""
use LiveBeatsWeb, :live_component
def show_modal(module, attrs) do
send_update(__MODULE__, id: "layout", show: Enum.into(attrs, %{module: module}))
end
def hide_modal do
send_update(__MODULE__, id: "layout", show: nil)
end
def update(%{id: id} = assigns, socket) do
show =
case assigns[:show] do
%{module: _module, confirm: {text, attrs}} = show ->
show
|> Map.put_new(:patch_to, nil)
|> Map.put_new(:redirect_to, nil)
|> Map.merge(%{confirm_text: text, confirm_attrs: attrs})
nil ->
nil
end
{:ok, assign(socket, id: id, show: show)}
end
def render(assigns) do
~H"""
<div class={unless @show, do: "hidden"}>
<%= if @show do %>
<.modal show id={@id} redirect_to={@show.redirect_to} patch_to={@show.patch_to}>
<.live_component module={@show.module} {@show} />
<:cancel>Cancel</:cancel>
<:confirm {@show.confirm_attrs}><%= @show.confirm_text %></:confirm>
</.modal>
<% end %>
</div>
"""
end
end

View file

@ -82,27 +82,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
) )
end end
def show_modal(%JS{} = js, id, opts) when is_binary(id) and is_list(opts) do def show_modal(js \\ %JS{}, id) when is_binary(id) do
on_confirm = Keyword.get(opts, :on_confirm, %JS{}) |> hide_modal(id)
title = Keyword.get(opts, :title, "")
content = Keyword.get(opts, :content, "")
js
|> JS.inner_text(title, to: "##{id}-title")
|> JS.inner_text(content, to: "##{id}-content")
|> JS.set_attribute("phx-click", to: "##{id}-confirm", value: on_confirm)
|> show_modal(id)
end
def show_modal(id) when is_binary(id) do
show_modal(%JS{}, id, [])
end
def show_modal(id, opts) when is_binary(id) and is_list(opts) do
show_modal(%JS{}, id, opts)
end
def show_modal(%JS{} = js, id) when is_binary(id) do
js js
|> JS.show( |> JS.show(
to: "##{id}", to: "##{id}",
@ -116,6 +96,7 @@ defmodule LiveBeatsWeb.LiveHelpers do
{"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95", {"ease-out duration-300", "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
"opacity-100 translate-y-0 sm:scale-100"} "opacity-100 translate-y-0 sm:scale-100"}
) )
|> js_exec("##{id}-confirm", "focus", [])
end end
def hide_modal(js \\ %JS{}, id) do def hide_modal(js \\ %JS{}, id) do
@ -138,8 +119,8 @@ defmodule LiveBeatsWeb.LiveHelpers do
assigns = assigns =
assigns assigns
|> assign_new(:show, fn -> false end) |> assign_new(:show, fn -> false end)
|> assign_new(:loading, fn -> false end) |> assign_new(:patch_to, fn -> nil end)
|> assign_new(:return_to, fn -> nil end) |> assign_new(:redirect_to, fn -> nil end)
|> assign_new(:on_cancel, fn -> %JS{} end) |> assign_new(:on_cancel, fn -> %JS{} end)
|> assign_new(:on_confirm, fn -> %JS{} end) |> assign_new(:on_confirm, fn -> %JS{} end)
# slots # slots
@ -158,13 +139,16 @@ defmodule LiveBeatsWeb.LiveHelpers do
phx-window-keydown={hide_modal(@on_cancel, @id)} phx-key="escape" phx-window-keydown={hide_modal(@on_cancel, @id)} phx-key="escape"
phx-click-away={hide_modal(@on_cancel, @id)} phx-click-away={hide_modal(@on_cancel, @id)}
> >
<%= if @return_to do %> <%= if @patch_to do %>
<%= live_redirect "close", to: @return_to, data: [modal_return: true], class: "hidden" %> <.link patch_to={@patch_to} data-modal-return class="hidden"></.link>
<% end %>
<%= if @redirect_to do %>
<.link redirect_to={@redirect_to} data-modal-return class="hidden"></.link>
<% end %> <% end %>
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
<div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}> <div class={"mx-auto flex-shrink-0 flex items-center justify-center h-8 w-8 rounded-full bg-purple-100 sm:mx-0"}>
<!-- Heroicon name: outline/plus --> <!-- Heroicon name: outline/plus -->
<svg xmlns="http://www.w3.org/2000/svg" class={"#{if @loading, do: "animate-ping"} h-6 w-6 text-purple-600"} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg> </svg>
</div> </div>
@ -173,31 +157,31 @@ defmodule LiveBeatsWeb.LiveHelpers do
<%= render_slot(@title) %> <%= render_slot(@title) %>
</h3> </h3>
<div class="mt-2"> <div class="mt-2">
<p id={"#{@id}-content"} class={"text-sm text-gray-500 #{if @loading, do: "invisible"}"}> <p id={"#{@id}-content"} class={"text-sm text-gray-500"}>
<%= render_slot(@inner_block) %> <%= render_slot(@inner_block) %>
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div class={"mt-5 sm:mt-4 sm:flex sm:flex-row-reverse #{if @loading, do: "invisible"}"}> <div class="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
<%= for confirm <- @confirm do %> <%= for confirm <- @confirm do %>
<button <button
id={"#{@id}-confirm"} id={"#{@id}-confirm"}
type="button"
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:ml-3 sm:w-auto sm:text-sm"
phx-click={@on_confirm |> hide_modal(@id)} phx-click={@on_confirm}
phx-disable-with
tabindex="1" tabindex="1"
autofocus {assigns_to_attributes(confirm)}
> >
<%= render_slot(confirm) %> <%= render_slot(confirm) %>
</button> </button>
<% end %> <% end %>
<%= for cancel <- @cancel do %> <%= for cancel <- @cancel do %>
<button <button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:w-auto sm:text-sm"
phx-click={hide_modal(@on_cancel, @id)} phx-click={hide_modal(@on_cancel, @id)}
tabindex="2" tabindex="2"
{assigns_to_attributes(cancel)}
> >
<%= render_slot(cancel) %> <%= render_slot(cancel) %>
</button> </button>
@ -218,9 +202,9 @@ defmodule LiveBeatsWeb.LiveHelpers do
~H""" ~H"""
<div class="bg-gray-200 flex-auto dark:bg-black rounded-full overflow-hidden" phx-update="ignore"> <div class="bg-gray-200 flex-auto dark:bg-black rounded-full overflow-hidden" phx-update="ignore">
<div id="progress" <div
id={@id}
class="bg-lime-500 dark:bg-lime-400 h-1.5 w-0" class="bg-lime-500 dark:bg-lime-400 h-1.5 w-0"
phx-hook="Progress"
data-min={@min} data-min={@min}
data-max={@max} data-max={@max}
data-val={@value}> data-val={@value}>
@ -300,14 +284,11 @@ defmodule LiveBeatsWeb.LiveHelpers do
</tr> </tr>
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-100"> <tbody class="bg-white divide-y divide-gray-100">
<%= for row <- @rows do %> <%= for {row, i} <- Enum.with_index(@rows) do %>
<tr id={@row_id && @row_id.(row)} class="hover:bg-gray-50"> <tr id={@row_id && @row_id.(row)} class="hover:bg-gray-50">
<%= for {col, i} <- Enum.with_index(@col) do %> <%= for col <- @col do %>
<td class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full"}"}> <td class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full"}"}>
<div class="flex items-center space-x-3 lg:pl-2"> <div class="flex items-center space-x-3 lg:pl-2">
<%= if i == 0 do %>
<div class="flex-shrink-0 w-2.5 h-2.5 rounded-full bg-pink-600 mr-2" aria-hidden="true"></div>
<% end %>
<%= render_slot(col, row) %> <%= render_slot(col, row) %>
</div> </div>
</td> </td>
@ -320,4 +301,52 @@ defmodule LiveBeatsWeb.LiveHelpers do
</div> </div>
""" """
end end
def live_table(assigns) do
assigns =
assigns
|> assign_new(:row_id, fn -> false end)
|> assign_new(:active_id, fn -> nil end)
~H"""
<div class="hidden mt-8 sm:block">
<div class="align-middle inline-block min-w-full border-b border-gray-200">
<table class="min-w-full">
<thead>
<tr class="border-t border-gray-200">
<%= for col <- @col do %>
<th
class="px-6 py-3 border-b border-gray-200 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<span class="lg:pl-2"><%= col.label %></span>
</th>
<% end %>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-100">
<%= for {row, i} <- Enum.with_index(@rows) do %>
<.live_component
module={@module}
id={@row_id.(row)}
row={row} col={@col}
index={i}
active_id={@active_id}
class="hover:bg-gray-50"
/>
<% end %>
</tbody>
</table>
</div>
</div>
"""
end
@doc """
Calls a wired up event listener to call a function with arguments.
window.addEventListener("js:exec", e => e.target[e.detail.call](...e.detail.args))
"""
def js_exec(js \\ %JS{}, to, call, args) do
JS.dispatch(js, "js:exec", to: to, detail: %{call: call, args: args})
end
end end

View file

@ -1,28 +0,0 @@
defmodule LiveBeatsWeb.ModalComponent do
use LiveBeatsWeb, :live_component
@impl true
def render(assigns) do
~H"""
<div
id={@id}
class="phx-modal"
phx-capture-click="close"
phx-window-keydown="close"
phx-key="escape"
phx-target={@myself}
phx-page-loading>
<div class="phx-modal-content">
<%= live_patch raw("&times;"), to: @return_to, class: "phx-modal-close" %>
<%= live_component @component, @opts %>
</div>
</div>
"""
end
@impl true
def handle_event("close", _, socket) do
{:noreply, push_patch(socket, to: socket.assigns.return_to)}
end
end

View file

@ -1,30 +1,37 @@
defmodule LiveBeatsWeb.PlayerLive do defmodule LiveBeatsWeb.PlayerLive do
use LiveBeatsWeb, :live_view use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary
alias LiveBeats.MediaLibrary.Song
on_mount {LiveBeatsWeb.UserAuth, :current_user} on_mount {LiveBeatsWeb.UserAuth, :current_user}
def render(assigns) do def render(assigns) do
~H""" ~H"""
<!-- player --> <!-- player -->
<div class="w-full"> <div id="audio-player" phx-hook="AudioPlayer" class="w-full" >
<div phx-update="ignore">
<audio></audio>
</div>
<div class="bg-white dark:bg-gray-800 p-4"> <div class="bg-white dark:bg-gray-800 p-4">
<div class="flex items-center space-x-3.5 sm:space-x-5 lg:space-x-3.5 xl:space-x-5"> <div class="flex items-center space-x-3.5 sm:space-x-5 lg:space-x-3.5 xl:space-x-5">
<div class="pr-5"> <div class="pr-5">
<div class="min-w-0 flex-col space-y-0.5"> <div class="min-w-0 flex-col space-y-0.5">
<h2 class="text-black dark:text-white text-sm sm:text-sm lg:text-sm xl:text-sm font-semibold truncate"> <h2 class="text-black dark:text-white text-sm sm:text-sm lg:text-sm xl:text-sm font-semibold truncate">
Black Sands <%= if @song, do: @song.title, else: raw("&nbsp;") %>
</h2> </h2>
<p class="text-gray-500 dark:text-gray-400 text-sm sm:text-sm lg:text-sm xl:text-sm font-medium"> <p class="text-gray-500 dark:text-gray-400 text-sm sm:text-sm lg:text-sm xl:text-sm font-medium">
Bonobo <%= if @song, do: @song.artist, else: raw("&nbsp;") %>
</p> </p>
</div> </div>
</div> </div>
<.progress_bar /> <.progress_bar id="player-progress" />
<div class="text-gray-500 dark:text-gray-400 flex-row justify-between text-sm font-medium tabular-nums"> <div class="text-gray-500 dark:text-gray-400 flex-row justify-between text-sm font-medium tabular-nums"
<div><%= @time %></div> phx-update="ignore">
<div><%= @count %></div> <div id="player-time"></div>
<div id="player-duration"></div>
</div> </div>
</div> </div>
</div> </div>
@ -46,12 +53,21 @@ defmodule LiveBeatsWeb.PlayerLive do
<path d="M17 0L9 6l8 6V0z" fill="currentColor" /> <path d="M17 0L9 6l8 6V0z" fill="currentColor" />
</svg> </svg>
</button> </button>
<button type="button" class="mx-auto scale-75"> <!-- pause -->
<svg width="50" height="50" fill="none"> <button type="button" class="mx-auto scale-75" phx-click={JS.push("play_pause") |> js_play_pause()}>
<circle class="text-gray-300 dark:text-gray-500" cx="25" cy="25" r="24" stroke="currentColor" stroke-width="1.5" /> <%= if @playing do %>
<path d="M18 16h4v18h-4V16zM28 16h4v18h-4z" fill="currentColor" /> <svg id="player-pause" width="50" height="50" fill="none">
</svg> <circle class="text-gray-300 dark:text-gray-500" cx="25" cy="25" r="24" stroke="currentColor" stroke-width="1.5" />
<path d="M18 16h4v18h-4V16zM28 16h4v18h-4z" fill="currentColor" />
</svg>
<% else %>
<svg id="player-play" width="50" height="50" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<circle id="svg_1" stroke-width="0.8" stroke="currentColor" r="11.4" cy="12" cx="12" class="text-gray-300 dark:text-gray-500"/>
<path stroke="null" fill="currentColor" transform="rotate(90 12.8947 12.3097)" id="svg_6" d="m9.40275,15.10014l3.49194,-5.58088l3.49197,5.58088l-6.98391,0z" stroke-width="1.5" fill="none"/>
</svg>
<% end %>
</button> </button>
<!-- /pause -->
<button type="button" class="mx-auto scale-75"> <button type="button" class="mx-auto scale-75">
<svg width="34" height="39" fill="none"> <svg width="34" height="39" fill="none">
<path d="M12.878 26.12c1.781 0 3.09-1.066 3.085-2.515.004-1.104-.665-1.896-1.824-2.075v-.068c.912-.235 1.505-.95 1.5-1.93.005-1.283-1.048-2.379-2.727-2.379-1.602 0-2.89.968-2.932 2.387h1.274c.03-.801.784-1.287 1.64-1.287.892 0 1.475.541 1.471 1.346.004.844-.673 1.398-1.64 1.398h-.738v1.074h.737c1.21 0 1.91.614 1.91 1.491 0 .848-.738 1.424-1.765 1.424-.946 0-1.683-.486-1.734-1.262H9.797c.055 1.424 1.317 2.395 3.08 2.395zm7.734.025c2.016 0 3.196-1.645 3.196-4.504 0-2.838-1.197-4.488-3.196-4.488-2.003 0-3.196 1.645-3.2 4.488 0 2.855 1.18 4.5 3.2 4.504zm0-1.138c-1.18 0-1.892-1.185-1.892-3.366.004-2.174.716-3.371 1.892-3.371 1.172 0 1.888 1.197 1.888 3.37 0 2.182-.712 3.367-1.888 3.367z" fill="currentColor" /> <path d="M12.878 26.12c1.781 0 3.09-1.066 3.085-2.515.004-1.104-.665-1.896-1.824-2.075v-.068c.912-.235 1.505-.95 1.5-1.93.005-1.283-1.048-2.379-2.727-2.379-1.602 0-2.89.968-2.932 2.387h1.274c.03-.801.784-1.287 1.64-1.287.892 0 1.475.541 1.471 1.346.004.844-.673 1.398-1.64 1.398h-.738v1.074h.737c1.21 0 1.91.614 1.91 1.491 0 .848-.738 1.424-1.765 1.424-.946 0-1.683-.486-1.734-1.262H9.797c.055 1.424 1.317 2.395 3.08 2.395zm7.734.025c2.016 0 3.196-1.645 3.196-4.504 0-2.838-1.197-4.488-3.196-4.488-2.003 0-3.196 1.645-3.2 4.488 0 2.855 1.18 4.5 3.2 4.504zm0-1.138c-1.18 0-1.892-1.185-1.892-3.366.004-2.174.716-3.371 1.892-3.371 1.172 0 1.888 1.197 1.888 3.37 0 2.182-.712 3.367-1.888 3.367z" fill="currentColor" />
@ -70,18 +86,68 @@ defmodule LiveBeatsWeb.PlayerLive do
</button> </button>
</div> </div>
<%= if @error do %>
<.modal show id="enable-audio" on_confirm={js_play_pause() |> hide_modal("enable-audio")}>
<:title>Start Listening now</:title>
Your browser needs a click event to enable playback
<:confirm>Listen Now</:confirm>
</.modal>
<% end %>
</div> </div>
<!-- /player --> <!-- /player -->
""" """
end end
def mount(_parmas, _session, socket) do def mount(_parmas, _session, socket) do
# if connected?(socket), do: Process.send_after(self(), :tick, 1000) if connected?(socket) and socket.assigns.current_user do
{:ok, assign(socket, time: inspect(System.system_time()), count: 0), layout: false} MediaLibrary.subscribe(socket.assigns.current_user)
end
{:ok, assign(socket, song: nil, playing: false, error: false), layout: false}
end end
def handle_info(:tick, socket) do def handle_event("play_pause", _, socket) do
Process.send_after(self(), :tick, 1000) %{song: song, playing: playing} = socket.assigns
{:noreply, update(socket, :count, &(&1 + 1))}
IO.inspect({:play_pause, playing})
cond do
song && playing ->
MediaLibrary.pause_song(song)
{:noreply, assign(socket, playing: false)}
song ->
MediaLibrary.play_song(song)
{:noreply, assign(socket, playing: true)}
true ->
{:noreply, assign(socket, playing: false)}
end
end
def handle_event("audio-rejected", _, socket) do
{:noreply, assign(socket, error: true)}
end
def handle_event("audio-accepted", _, socket) do
{:noreply, assign(socket, error: false)}
end
def handle_info({:pause, _}, socket) do
{:noreply,
socket
|> push_event("pause", %{})
|> 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)}
end
defp js_play_pause(js \\ %JS{}) do
JS.dispatch(js, "js:play_pause", to: "#audio-player")
end end
end end

View file

@ -1,47 +0,0 @@
defmodule LiveBeatsWeb.SongLive.DeleteDialogComponent do
use LiveBeatsWeb, :live_component
alias LiveBeats.MediaLibrary
def send_show(%MediaLibrary.Song{} = song) do
send_update(__MODULE__, id: "delete-modal", show: song)
end
@impl true
def render(assigns) do
~H"""
<div>
<.modal
id="delete-modal"
loading={is_nil(@song.id)}
on_confirm={JS.push("confirm-delete", value: %{id: @song.id}) |> hide("#song-#{@song.id}")}
on_cancel={JS.push("cancel", target: @myself)}>
Are you sure you want to delete "<%= @song.title %>"?
<:cancel>Cancel</:cancel>
<:confirm>Delete</:confirm>
</.modal>
</div>
"""
end
@impl true
def update(%{show: %MediaLibrary.Song{} = song}, socket) do
{:ok, assign(socket, song: song)}
end
def update(%{} = _assigns, socket) do
{:ok, assign_defaults(socket)}
end
@impl true
def handle_event("cancel", _, socket) do
IO.inspect({:cancel})
{:noreply, assign_defaults(socket)}
end
defp assign_defaults(socket) do
assign(socket, song: %MediaLibrary.Song{})
end
end

View file

@ -1,102 +0,0 @@
defmodule LiveBeatsWeb.SongLive.FormComponent do
use LiveBeatsWeb, :live_component
alias LiveBeats.{MediaLibrary, ID3}
@impl true
def update(%{song: song} = assigns, socket) do
changeset = MediaLibrary.change_song(song)
{:ok,
socket
|> assign(assigns)
|> assign(changeset: changeset, tmp_path: nil)
|> allow_upload(:mp3,
auto_upload: true,
progress: &handle_progress/3,
accept: ~w(.mp3),
max_entries: 1,
max_file_size: 20_000_000
)}
end
@impl true
def handle_event("validate", %{"song" => song_params}, socket) do
changeset =
socket.assigns.song
|> MediaLibrary.change_song(song_params)
|> Map.put(:action, :validate)
{:noreply, assign(socket, :changeset, changeset)}
end
def handle_event("save", %{"song" => song_params}, socket) do
IO.inspect({:save, song_params})
save_song(socket, socket.assigns.action, song_params)
end
defp save_song(socket, :edit, song_params) do
case MediaLibrary.update_song(socket.assigns.song, song_params) do
{:ok, _song} ->
{:noreply,
socket
|> put_flash(:info, "Song updated successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, :changeset, changeset)}
end
end
defp save_song(socket, :new, song_params) do
case MediaLibrary.create_song(song_params) do
{:ok, _song} ->
{:noreply,
socket
|> put_flash(:info, "Song created successfully")
|> push_redirect(to: socket.assigns.return_to)}
{:error, %Ecto.Changeset{} = changeset} ->
{:noreply, assign(socket, changeset: changeset)}
end
end
defp handle_progress(:mp3, entry, socket) do
changeset = socket.assigns.changeset
if entry.done? do
new_socket =
consume_uploaded_entry(socket, entry, fn %{} = meta ->
case ID3.parse(meta.path) do
{:ok, %ID3{} = id3} ->
new_changeset =
changeset
|> Ecto.Changeset.put_change(:title, id3.title)
|> Ecto.Changeset.put_change(:artist, id3.artist)
socket
|> assign(changeset: new_changeset)
|> put_tmp_mp3(meta.path)
{:error, _} ->
put_tmp_mp3(socket, meta.path)
end
end)
{:noreply, new_socket}
else
{:noreply, socket}
end
end
defp put_tmp_mp3(socket, path) do
if socket.assigns.tmp_path, do: File.rm!(socket.assigns.tmp_path)
{:ok, tmp_path} = Plug.Upload.random_file("temp_mp3")
File.cp!(path, tmp_path)
assign(socket, tmp_path: tmp_path)
end
defp file_error(%{kind: :too_large} = assigns), do: ~H|larger than 10MB|
defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file|
end

View file

@ -1,131 +0,0 @@
<div>
<h2><%= @title %></h2>
<.form
let={f}
for={@changeset}
id="song-form"
class="space-y-8 divide-y divide-gray-200"
phx-target={@myself}
phx-change="validate"
phx-submit="save">
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
<div class="pt-8 space-y-6 sm:pt-10 sm:space-y-5">
<div class="space-y-6 sm:space-y-5">
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="song-form_title" class="block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2">
Title
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<%= text_input f, :title, class: "max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md" %>
</div>
</div>
<%= error_tag f, :title %>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="song-form_artist" class="block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2">
Artist
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<%= text_input f, :artist, class: "max-w-lg block w-full shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:max-w-xs sm:text-sm border-gray-300 rounded-md" %>
</div>
</div>
<%= error_tag f, :artist %>
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="country" class="block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2">
Genre
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2">
<select id="song-form_genre_id" name="genre_id" class="max-w-lg block focus:ring-indigo-500 focus:border-indigo-500 w-full shadow-sm sm:max-w-xs sm:text-sm border-gray-300 rounded-md">
<%= for genre <- @genres do %>
<option value={genre.id}><%= genre.title %></option>
<% end %>
</select>
</div>
</div>
<!-- upload -->
<div class="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
<label for="cover-photo" class="block text-sm font-medium text-gray-700 sm:mt-px sm:pt-2">
MP3
</label>
<div class="mt-1 sm:mt-0 sm:col-span-2" phx-drop-target={@uploads.mp3.ref}>
<%= if Enum.any?(@uploads.mp3.errors) do %>
<div class="rounded-md bg-red-50 p-4 mb-2">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" x-description="Heroicon name: solid/x-circle" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
Oops!
</h3>
<div class="mt-2 text-sm text-red-700">
<ul role="list" class="list-disc pl-5 space-y-1">
<%= for {_ref, error} <- @uploads.mp3.errors do %>
<li><.file_error kind={error} /></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div class="max-w-lg flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<div class="flex text-sm text-gray-600">
<label for="file-upload" class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
<span phx-click={JS.dispatch("click", to: "##{@uploads.mp3.ref}")}>Upload a file</span>
<%= live_file_input @uploads.mp3, class: "sr-only" %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500">
MP3 up to 20MB
</p>
<%= if Enum.any?(@uploads.mp3.entries) do %>
<br/>
<%= for entry <- @uploads.mp3.entries do %>
<div class="ring-4 ring-purple-100 rounded-full">
<%= entry.client_name %>
</div>
<br/>
<div class="bg-gray-200 flex-auto rounded-full overflow-hidden">
<div id="progress"
class="bg-purple-500 dark:bg-purple-400 h-1.5 w-0"
style={"width: #{entry.progress}%;"}>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
</div>
<!-- /upload -->
</div>
</div>
</div>
<div class="pt-5">
<div class="flex justify-end">
<button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
<button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save
</button>
</div>
</div>
</.form>
</div>

View file

@ -1,9 +1,9 @@
defmodule LiveBeatsWeb.SongLive.Index do defmodule LiveBeatsWeb.SongLive.Index do
use LiveBeatsWeb, :live_view use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary alias LiveBeats.{MediaLibrary, MP3Stat}
alias LiveBeats.MediaLibrary.Song alias LiveBeats.MediaLibrary.Song
alias LiveBeatsWeb.SongLive.DeleteDialogComponent alias LiveBeatsWeb.LayoutComponent
def render(assigns) do def render(assigns) do
~H""" ~H"""
@ -15,51 +15,107 @@ defmodule LiveBeatsWeb.SongLive.Index do
</:actions> </:actions>
</.title_bar> </.title_bar>
<%= if @live_action in [:new, :edit] do %> <%= for song <- @songs, id = "delete-modal-#{song.id}" do %>
<.modal show id="add-songs" return_to={Routes.song_index_path(@socket, :index)}> <.modal
<.live_component id={id}
module={LiveBeatsWeb.SongLive.UploadFormComponent} on_confirm={JS.push("delete", value: %{id: song.id}) |> hide_modal(id) |> hide("#song-#{song.id}")}
title={@page_title} >
id={@song.id || :new} Are you sure you want to delete "<%= song.title %>"?
action={@live_action} <:cancel>Cancel</:cancel>
return_to={Routes.song_index_path(@socket, :index)} <:confirm>Delete</:confirm>
song={@song}
genres={@genres}
/>
</.modal> </.modal>
<% end %> <% end %>
<.live_table
<.modal id="delete-modal"> module={LiveBeatsWeb.SongLive.SongRow}
<:cancel>Cancel</:cancel> rows={@songs}
<:confirm>Delete</:confirm> row_id={fn song -> "song-#{song.id}" end}
</.modal> >
<:col let={%{song: song}} label="Title"><%= song.title %></:col>
<.table rows={@songs} row_id={fn song -> "song-#{song.id}" end}> <:col let={%{song: song}} label="Artist"><%= song.artist %></:col>
<:col let={song} label="Title"><%= song.title %></:col> <:col let={%{song: song}} label="Duration"><%= MP3Stat.to_mmss(song.duration) %></:col>
<:col let={song} label="Artist"><%= song.artist %></:col> <:col let={%{song: song}} label="">
<:col let={song} label="Duration"><%= song.duration %></:col> <.link phx-click={show_modal("delete-modal-#{song.id}")} class="inline-flex items-center px-3 py-2 text-sm leading-4 font-medium">
<:col let={song} label=""> <svg xmlns="http://www.w3.org/2000/svg" class="-ml-0.5 mr-2 h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
<.link redirect_to={Routes.song_show_path(@socket, :show, song)}>Show</.link> <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
<.link patch_to={Routes.song_index_path(@socket, :edit, song)}>Edit</.link> </svg>
<.link phx-click={ Delete
show_modal( </.link>
"delete-modal",
content: "Are you sure you want to delete \"#{song.title}\"?",
on_confirm: JS.push("delete", value: %{id: song.id}) |> hide("#song-#{song.id}")
)
}>Delete</.link>
</:col> </:col>
</.table> </.live_table>
""" """
end end
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, assign(socket, :songs, list_songs())} if connected?(socket) do
MediaLibrary.subscribe(socket.assigns.current_user)
end
{:ok, assign(socket, songs: list_songs(), active_id: nil)}
end end
def handle_params(params, _url, socket) do def handle_params(params, _url, socket) do
{:noreply, apply_action(socket, socket.assigns.live_action, params)} {: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)
{:noreply, socket}
end
def handle_event("delete", %{"id" => id}, socket) do
song = MediaLibrary.get_song!(id)
{:ok, _} = MediaLibrary.delete_song(song)
{:noreply, socket}
end
def handle_info({_ref, {:duration, entry_ref, result}}, socket) do
IO.inspect({:async_duration, entry_ref, result})
{:noreply, socket}
end
def handle_info({:play, %Song{} = song, _meta}, socket) do
{:noreply, play_song(socket, song)}
end
def handle_info({:pause, %Song{} = song}, socket) do
{:noreply, pause_song(socket, song.id)}
end
defp pause_song(socket, song_id) do
if old = Enum.find(socket.assigns.songs, fn song -> song.id == song_id end) do
send_update(LiveBeatsWeb.SongLive.SongRow, id: "song-#{old.id}", action: :deactivate)
end
socket
end
defp play_song(socket, %Song{} = song) do
socket = pause_song(socket, socket.assigns.active_id)
next = Enum.find(socket.assigns.songs, &(&1.id == song.id))
if next do
send_update(LiveBeatsWeb.SongLive.SongRow, id: "song-#{next.id}", action: :activate)
end
assign(socket, active_id: next.id)
end
defp maybe_show_modal(socket) do
if socket.assigns.live_action in [:new, :edit] do
LayoutComponent.show_modal(LiveBeatsWeb.SongLive.UploadFormComponent, %{
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
})
else
LayoutComponent.hide_modal()
end
socket
end end
defp apply_action(socket, :edit, %{"id" => id}) do defp apply_action(socket, :edit, %{"id" => id}) do
@ -70,7 +126,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
defp apply_action(socket, :new, _params) do defp apply_action(socket, :new, _params) do
socket socket
|> assign(:page_title, "New Song") |> assign(:page_title, "Add Songs")
|> assign(:song, %Song{}) |> assign(:song, %Song{})
end end
@ -80,13 +136,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
|> assign(:song, nil) |> assign(:song, nil)
end end
def handle_event("delete", %{"id" => id}, socket) do
song = MediaLibrary.get_song!(id)
{:ok, _} = MediaLibrary.delete_song(song)
{:noreply, assign(socket, :songs, list_songs())}
end
defp list_songs do defp list_songs do
MediaLibrary.list_songs() MediaLibrary.list_songs(50)
end end
end end

View file

@ -1,21 +0,0 @@
defmodule LiveBeatsWeb.SongLive.Show do
use LiveBeatsWeb, :live_view
alias LiveBeats.MediaLibrary
@impl true
def mount(_params, _session, socket) do
{:ok, socket}
end
@impl true
def handle_params(%{"id" => id}, _, socket) do
{:noreply,
socket
|> assign(:page_title, page_title(socket.assigns.live_action))
|> assign(:song, MediaLibrary.get_song!(id))}
end
defp page_title(:show), do: "Show Song"
defp page_title(:edit), do: "Edit Song"
end

View file

@ -1,47 +0,0 @@
<h1>Show Song</h1>
<%= if @live_action in [:edit] do %>
<%#= live_modal LiveBeatsWeb.SongLive.FormComponent,
id: @song.id,
title: @page_title,
action: @live_action,
song: @song,
return_to: Routes.song_show_path(@socket, :show, @song) %>
<% end %>
<ul>
<li>
<strong>Album artist:</strong>
<%= @song.album_artist %>
</li>
<li>
<strong>Artist:</strong>
<%= @song.artist %>
</li>
<li>
<strong>Duration:</strong>
<%= @song.duration %>
</li>
<li>
<strong>Title:</strong>
<%= @song.title %>
</li>
<li>
<strong>Date recorded:</strong>
<%= @song.date_recorded %>
</li>
<li>
<strong>Date released:</strong>
<%= @song.date_released %>
</li>
</ul>
<span><%= live_patch "Edit", to: Routes.song_show_path(@socket, :edit, @song), class: "button" %></span> |
<span><%= live_redirect "Back", to: Routes.song_index_path(@socket, :index) %></span>

View file

@ -1,11 +1,19 @@
defmodule LiveBeatsWeb.SongLive.SongEntryComponent do defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
use LiveBeatsWeb, :live_component use LiveBeatsWeb, :live_component
alias LiveBeats.MP3Stat
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2"> <div class="sm:grid sm:grid-cols-2 sm:gap-2 sm:items-start sm:border-t sm:border-gray-200 sm:pt-2">
<div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600"> <div class="border border-gray-300 rounded-md px-3 py-2 mt-2 shadow-sm focus-within:ring-1 focus-within:ring-indigo-600 focus-within:border-indigo-600">
<label for="name" class="block text-xs font-medium text-gray-900">Title</label> <label for="name" class="block text-xs font-medium text-gray-900">
<%= if @duration do %>
Title <span class="text-gray-400">(<%= MP3Stat.to_mmss(@duration) %>)</span>
<% else %>
Title <span class="text-gray-400">(calculating duration...)</span>
<% end %>
</label>
<input type="text" name={"songs[#{@ref}][title]"} value={@title} <input type="text" name={"songs[#{@ref}][title]"} value={@title}
class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/> class="block w-full border-0 p-0 text-gray-900 placeholder-gray-500 focus:ring-0 sm:text-sm"/>
<%= error_tag(@errors, :title, "songs[#{@ref}][title]") %> <%= error_tag(@errors, :title, "songs[#{@ref}][title]") %>
@ -33,6 +41,7 @@ defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
|> assign(:errors, changeset.errors) |> assign(:errors, changeset.errors)
|> assign(title: Ecto.Changeset.get_field(changeset, :title)) |> assign(title: Ecto.Changeset.get_field(changeset, :title))
|> assign(artist: Ecto.Changeset.get_field(changeset, :artist)) |> assign(artist: Ecto.Changeset.get_field(changeset, :artist))
|> assign(duration: IO.inspect(Ecto.Changeset.get_field(changeset, :duration)))
|> assign_new(:progress, fn -> 0 end)} |> assign_new(:progress, fn -> 0 end)}
end end
end end

View file

@ -0,0 +1,60 @@
defmodule LiveBeatsWeb.SongLive.SongRow do
use LiveBeatsWeb, :live_component
def render(assigns) do
~H"""
<tr id={@id} class={@class}}>
<%= for {col, i} <- Enum.with_index(@col) do %>
<td
class={"px-6 py-3 whitespace-nowrap text-sm font-medium text-gray-900 #{if i == 0, do: "max-w-0 w-full cursor-pointer"}"}
phx-click={JS.push("play-song", value: %{id: @song.id})}
>
<div class="flex items-center space-x-3 lg:pl-2">
<%= if i == 0 do %>
<%= if @active do %>
<span class="flex pt-1 relative mr-2 w-4">
<span class="w-3 h-3 animate-ping bg-purple-400 rounded-full absolute"></span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 -mt-1 -ml-1" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
</svg>
</span>
<% else %>
<span class="flex relative w-6 -translate-x-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd" />
</svg>
</span>
<% end %>
<% end %>
<%= render_slot(col, assigns) %>
</div>
</td>
<% end %>
</tr>
"""
end
def update(%{action: :activate}, socket) do
{:ok, assign(socket, active: true)}
end
def update(%{action: :deactivate}, socket) do
{:ok, assign(socket, active: false)}
end
def update(%{action: action}, _socket) do
raise ArgumentError, "unkown action #{inspect(action)}"
end
def update(assigns, socket) do
{:ok,
assign(socket,
id: assigns.id,
song: assigns.row,
col: assigns.col,
class: assigns.class,
index: assigns.index,
active: false
)}
end
end

View file

@ -1,12 +1,22 @@
defmodule LiveBeatsWeb.SongLive.UploadFormComponent do defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
use LiveBeatsWeb, :live_component use LiveBeatsWeb, :live_component
alias LiveBeats.{MediaLibrary, ID3} alias LiveBeats.{MediaLibrary, MP3Stat}
alias LiveBeatsWeb.SongLive.SongEntryComponent alias LiveBeatsWeb.SongLive.SongEntryComponent
@max_songs 10 @max_songs 10
@impl true @impl true
def update(%{action: {:duration, entry_ref, result}}, socket) do
case result do
{:ok, %MP3Stat{} = stat} ->
{:ok, put_stats(socket, entry_ref, stat)}
_ ->
{:ok, cancel_upload(socket, :mp3, entry_ref)}
end
end
def update(%{song: song} = assigns, socket) do def update(%{song: song} = assigns, socket) do
{:ok, {:ok,
socket socket
@ -50,9 +60,10 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
end end
def handle_event("save", %{"songs" => song_params}, socket) do def handle_event("save", %{"songs" => song_params}, socket) do
%{current_user: current_user} = socket.assigns
changesets = socket.assigns.changesets changesets = socket.assigns.changesets
case MediaLibrary.import_songs(changesets, &consume_entry(socket, &1, &2)) do case MediaLibrary.import_songs(current_user, changesets, &consume_entry(socket, &1, &2)) do
{:ok, songs} -> {:ok, songs} ->
{:noreply, {:noreply,
socket socket
@ -92,16 +103,35 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
defp handle_progress(:mp3, entry, socket) do defp handle_progress(:mp3, entry, socket) do
send_update(SongEntryComponent, id: entry.ref, progress: entry.progress) send_update(SongEntryComponent, id: entry.ref, progress: entry.progress)
{:noreply, put_new_changeset(socket, entry)} lv = self()
end
defp put_tmp_mp3(changeset, path) do if entry.done? do
{:ok, tmp_path} = Plug.Upload.random_file("tmp_mp3") consume_uploaded_entry(socket, entry, fn %{path: path} ->
File.cp!(path, tmp_path) Task.Supervisor.start_child(LiveBeats.TaskSupervisor, fn ->
Ecto.Changeset.put_change(changeset, :tmp_mp3_path, tmp_path) result = LiveBeats.MP3Stat.parse(path)
send_update(lv, __MODULE__,
id: socket.assigns.id,
action: {:duration, entry.ref, result}
)
end)
{:postpone, :ok}
end)
end
{:noreply, put_new_changeset(socket, entry)}
end end
defp file_error(%{kind: :too_large} = assigns), do: ~H|larger than 10MB| defp file_error(%{kind: :too_large} = assigns), do: ~H|larger than 10MB|
defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file| defp file_error(%{kind: :not_accepted} = assigns), do: ~H|not a valid MP3 file|
defp file_error(%{kind: :too_many_files} = assigns), do: ~H|too many files| defp file_error(%{kind: :too_many_files} = assigns), do: ~H|too many files|
defp put_stats(socket, entry_ref, %MP3Stat{} = stat) do
if changeset = get_changeset(socket, entry_ref) do
update_changeset(socket, MediaLibrary.put_stats(changeset, stat), entry_ref)
else
socket
end
end
end end

View file

@ -63,19 +63,7 @@
</div> </div>
</div> </div>
<!-- /upload --> <!-- /upload -->
</div> </div>
</div> </div>
<div class="pt-5">
<div class="flex justify-end">
<button type="button" class="bg-white py-2 px-4 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Cancel
</button>
<button type="submit" class="ml-3 inline-flex justify-center py-2 px-4 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save
</button>
</div>
</div>
</.form> </.form>
</div> </div>

View file

@ -29,9 +29,6 @@ defmodule LiveBeatsWeb.Router do
live "/", HomeLive, :index live "/", HomeLive, :index
live "/songs", SongLive.Index, :index live "/songs", SongLive.Index, :index
live "/songs/new", SongLive.Index, :new live "/songs/new", SongLive.Index, :new
live "/songs/:id/edit", SongLive.Index, :edit
live "/songs/:id", SongLive.Show, :show
live "/songs/:id/show/edit", SongLive.Show, :edit
end end
end end

View file

@ -7,5 +7,7 @@
phx-click="lv:clear-flash" phx-click="lv:clear-flash"
phx-value-key="error"><%= live_flash(@flash, :error) %></p> phx-value-key="error"><%= live_flash(@flash, :error) %></p>
<.live_component module={LiveBeatsWeb.LayoutComponent} id="layout" />
<%= @inner_content %> <%= @inner_content %>
</main> </main>

View file

@ -30,6 +30,7 @@
"plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"}, "plug": {:hex, :plug, "1.12.1", "645678c800601d8d9f27ad1aebba1fdb9ce5b2623ddb961a074da0b96c35187d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d57e799a777bc20494b784966dc5fbda91eb4a09f571f76545b72a634ce0d30b"},
"plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"}, "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
"plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"},
"porcelain": {:hex, :porcelain, "2.0.3", "2d77b17d1f21fed875b8c5ecba72a01533db2013bd2e5e62c6d286c029150fdc", [:mix], [], "hexpm", "dc996ab8fadbc09912c787c7ab8673065e50ea1a6245177b0c24569013d23620"},
"postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"}, "postgrex": {:hex, :postgrex, "0.15.10", "2809dee1b1d76f7cbabe570b2a9285c2e7b41be60cf792f5f2804a54b838a067", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1560ca427542f6b213f8e281633ae1a3b31cdbcd84ebd7f50628765b8f6132be"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"rustler": {:hex, :rustler, "0.22.2", "f92d6dba71bef6fe5f0d955649cb071127adc92f32a78890e8fa9939e59a1b41", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.5.2", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "56b129141e86d60a2d670af9a2b55a9071e10933ef593034565af77e84655118"}, "rustler": {:hex, :rustler, "0.22.2", "f92d6dba71bef6fe5f0d955649cb071127adc92f32a78890e8fa9939e59a1b41", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:toml, "~> 0.5.2", [hex: :toml, repo: "hexpm", optional: false]}], "hexpm", "56b129141e86d60a2d670af9a2b55a9071e10933ef593034565af77e84655118"},

View file

@ -14,7 +14,7 @@
# {:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title}) # {:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
# end # end
for i <- 1..20 do for i <- 1..200 do
{:ok, _} = {:ok, _} =
LiveBeats.MediaLibrary.create_song(%{ LiveBeats.MediaLibrary.create_song(%{
artist: "Bonobo", artist: "Bonobo",