mirror of
https://github.com/fly-apps/live_beats.git
synced 2024-11-25 09:20:59 +00:00
Synced playing fixup
This commit is contained in:
parent
3ff4ae2bea
commit
287f78ab2a
13 changed files with 322 additions and 167 deletions
|
@ -3,15 +3,7 @@ import {Socket} from "phoenix"
|
||||||
import {LiveSocket} from "./phoenix_live_view"
|
import {LiveSocket} from "./phoenix_live_view"
|
||||||
import topbar from "../vendor/topbar"
|
import topbar from "../vendor/topbar"
|
||||||
|
|
||||||
let render = (webComponent, html) => {
|
let nowSeconds = () => Math.round(Date.now() / 1000)
|
||||||
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 Hooks = {}
|
let Hooks = {}
|
||||||
|
|
||||||
|
@ -40,23 +32,30 @@ Hooks.AudioPlayer = {
|
||||||
this.player.pause()
|
this.player.pause()
|
||||||
}
|
}
|
||||||
document.addEventListener("click", enableAudio)
|
document.addEventListener("click", enableAudio)
|
||||||
|
this.el.addEventListener("js:listen_now", () => this.play({sync: true}))
|
||||||
this.el.addEventListener("js:play_pause", () => {
|
this.el.addEventListener("js:play_pause", () => {
|
||||||
this.play()
|
if(this.player.paused){
|
||||||
|
this.play()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
this.handleEvent("play", ({url, began_at}) => {
|
this.handleEvent("play", ({url, elapsed}) => {
|
||||||
this.playbackBeganAt = began_at
|
this.playbackBeganAt = nowSeconds() - elapsed
|
||||||
this.player.src = url
|
if(this.player.src === url && this.player.paused){
|
||||||
this.play()
|
this.play({sync: true})
|
||||||
|
} else if(this.player.src !== url) {
|
||||||
|
this.player.src = url
|
||||||
|
this.play({sync: true})
|
||||||
|
}
|
||||||
})
|
})
|
||||||
this.handleEvent("pause", () => {
|
this.handleEvent("pause", () => {
|
||||||
console.log("Server Pause!")
|
|
||||||
this.pause()
|
this.pause()
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
play(){
|
play(opts = {}){
|
||||||
|
let {sync} = opts
|
||||||
this.player.play().then(() => {
|
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.progressTimer = setInterval(() => this.updateProgress(), 100)
|
||||||
this.pushEvent("audio-accepted", {})
|
this.pushEvent("audio-accepted", {})
|
||||||
}, error => {
|
}, error => {
|
||||||
|
@ -65,8 +64,8 @@ Hooks.AudioPlayer = {
|
||||||
},
|
},
|
||||||
|
|
||||||
pause(){
|
pause(){
|
||||||
this.player.pause()
|
|
||||||
clearInterval(this.progressTimer)
|
clearInterval(this.progressTimer)
|
||||||
|
this.player.pause()
|
||||||
},
|
},
|
||||||
|
|
||||||
updateProgress(){
|
updateProgress(){
|
||||||
|
|
|
@ -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
|
|
|
@ -3,12 +3,18 @@ defmodule LiveBeats.MediaLibrary do
|
||||||
The MediaLibrary context.
|
The MediaLibrary context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
import Ecto.Query, warn: false
|
import Ecto.Query, warn: false
|
||||||
alias LiveBeats.{Repo, MP3Stat, Accounts}
|
alias LiveBeats.{Repo, MP3Stat, Accounts}
|
||||||
alias LiveBeats.MediaLibrary.{Song, Genre}
|
alias LiveBeats.MediaLibrary.{Song, Genre}
|
||||||
|
alias Ecto.{Multi, Changeset}
|
||||||
|
|
||||||
@pubsub LiveBeats.PubSub
|
@pubsub LiveBeats.PubSub
|
||||||
|
|
||||||
|
defdelegate stopped?(song), to: Song
|
||||||
|
defdelegate playing?(song), to: Song
|
||||||
|
defdelegate paused?(song), to: Song
|
||||||
|
|
||||||
def subscribe(%Accounts.User{} = user) do
|
def subscribe(%Accounts.User{} = user) do
|
||||||
Phoenix.PubSub.subscribe(@pubsub, topic(user.id))
|
Phoenix.PubSub.subscribe(@pubsub, topic(user.id))
|
||||||
end
|
end
|
||||||
|
@ -17,19 +23,65 @@ defmodule LiveBeats.MediaLibrary do
|
||||||
|
|
||||||
def play_song(id) do
|
def play_song(id) do
|
||||||
song = get_song!(id)
|
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
|
end
|
||||||
|
|
||||||
def pause_song(%Song{} = song) do
|
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})
|
Phoenix.PubSub.broadcast!(@pubsub, topic(song.user_id), {:pause, song})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp topic(user_id), do: "room:#{user_id}"
|
defp topic(user_id), do: "room:#{user_id}"
|
||||||
|
|
||||||
def store_mp3(%Song{} = song, tmp_path) do
|
def store_mp3(%Song{} = song, tmp_path) do
|
||||||
dir = "priv/static/uploads/songs"
|
File.mkdir_p!(Path.dirname(song.mp3_filepath))
|
||||||
File.mkdir_p!(dir)
|
File.cp!(tmp_path, song.mp3_filepath)
|
||||||
File.cp!(tmp_path, Path.join(dir, song.mp3_filename))
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def put_stats(%Ecto.Changeset{} = changeset, %MP3Stat{} = stat) do
|
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)
|
def import_songs(%Accounts.User{} = user, changesets, consume_file)
|
||||||
when is_map(changesets) and is_function(consume_file, 2) do
|
when is_map(changesets) and is_function(consume_file, 2) do
|
||||||
changesets
|
multi =
|
||||||
|> Enum.reduce(Ecto.Multi.new(), fn {ref, chset}, acc ->
|
Enum.reduce(changesets, Ecto.Multi.new(), fn {ref, chset}, acc ->
|
||||||
chset =
|
chset =
|
||||||
chset
|
chset
|
||||||
|> Song.put_user(user)
|
|> Song.put_user(user)
|
||||||
|> Song.put_mp3_path()
|
|> Song.put_mp3_path()
|
||||||
|> Map.put(:action, nil)
|
|
||||||
|
|
||||||
Ecto.Multi.insert(acc, {:song, ref}, chset)
|
Ecto.Multi.insert(acc, {:song, ref}, chset)
|
||||||
end)
|
end)
|
||||||
|> LiveBeats.Repo.transaction()
|
|
||||||
|> case do
|
case LiveBeats.Repo.transaction(multi) do
|
||||||
{:ok, results} ->
|
{:ok, results} ->
|
||||||
{:ok,
|
{:ok,
|
||||||
results
|
results
|
||||||
|
@ -83,7 +134,25 @@ defmodule LiveBeats.MediaLibrary do
|
||||||
end
|
end
|
||||||
|
|
||||||
def list_songs(limit \\ 100) do
|
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
|
end
|
||||||
|
|
||||||
def get_song!(id), do: Repo.get!(Song, id)
|
def get_song!(id), do: Repo.get!(Song, id)
|
||||||
|
@ -101,12 +170,20 @@ defmodule LiveBeats.MediaLibrary do
|
||||||
end
|
end
|
||||||
|
|
||||||
def delete_song(%Song{} = song) do
|
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)
|
Repo.delete(song)
|
||||||
end
|
end
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -2,23 +2,31 @@ defmodule LiveBeats.MediaLibrary.Song do
|
||||||
use Ecto.Schema
|
use Ecto.Schema
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
|
alias LiveBeats.MediaLibrary.Song
|
||||||
alias LiveBeats.Accounts
|
alias LiveBeats.Accounts
|
||||||
|
|
||||||
schema "songs" do
|
schema "songs" do
|
||||||
field :album_artist, :string
|
field :album_artist, :string
|
||||||
field :artist, :string
|
field :artist, :string
|
||||||
|
field :played_at, :utc_datetime
|
||||||
|
field :paused_at, :utc_datetime
|
||||||
field :date_recorded, :naive_datetime
|
field :date_recorded, :naive_datetime
|
||||||
field :date_released, :naive_datetime
|
field :date_released, :naive_datetime
|
||||||
field :duration, :integer
|
field :duration, :integer
|
||||||
|
field :status, Ecto.Enum, values: [stopped: 1, playing: 2, paused: 3]
|
||||||
field :title, :string
|
field :title, :string
|
||||||
field :mp3_path, :string
|
field :mp3_path, :string
|
||||||
field :mp3_filename, :string
|
field :mp3_filepath, :string
|
||||||
belongs_to :user, Accounts.User
|
belongs_to :user, Accounts.User
|
||||||
belongs_to :genre, LiveBeats.MediaLibrary.Genre
|
belongs_to :genre, LiveBeats.MediaLibrary.Genre
|
||||||
|
|
||||||
timestamps()
|
timestamps()
|
||||||
end
|
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
|
@doc false
|
||||||
def changeset(song, attrs) do
|
def changeset(song, attrs) do
|
||||||
song
|
song
|
||||||
|
@ -34,10 +42,11 @@ defmodule LiveBeats.MediaLibrary.Song do
|
||||||
def put_mp3_path(%Ecto.Changeset{} = changeset) do
|
def put_mp3_path(%Ecto.Changeset{} = changeset) do
|
||||||
if changeset.valid? do
|
if changeset.valid? do
|
||||||
filename = Ecto.UUID.generate() <> ".mp3"
|
filename = Ecto.UUID.generate() <> ".mp3"
|
||||||
|
filepath = Path.join("priv/static/uploads/songs", filename)
|
||||||
|
|
||||||
changeset
|
changeset
|
||||||
|> Ecto.Changeset.put_change(:mp3_filename, filename)
|
|> Ecto.Changeset.put_change(:mp3_filepath, filepath)
|
||||||
|> Ecto.Changeset.put_change(:mp3_path, "uploads/songs/#{filename}")
|
|> Ecto.Changeset.put_change(:mp3_path, Path.join("uploads/songs", filename))
|
||||||
else
|
else
|
||||||
changeset
|
changeset
|
||||||
end
|
end
|
||||||
|
|
|
@ -87,7 +87,7 @@ defmodule LiveBeatsWeb.PlayerLive do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @error 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</:title>
|
<:title>Start Listening now</:title>
|
||||||
Your browser needs a click event to enable playback
|
Your browser needs a click event to enable playback
|
||||||
<:confirm>Listen Now</:confirm>
|
<:confirm>Listen Now</:confirm>
|
||||||
|
@ -101,16 +101,25 @@ defmodule LiveBeatsWeb.PlayerLive do
|
||||||
def mount(_parmas, _session, socket) do
|
def mount(_parmas, _session, socket) do
|
||||||
if connected?(socket) and socket.assigns.current_user do
|
if connected?(socket) and socket.assigns.current_user do
|
||||||
MediaLibrary.subscribe(socket.assigns.current_user)
|
MediaLibrary.subscribe(socket.assigns.current_user)
|
||||||
|
send(self(), :play_current)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def handle_event("play_pause", _, socket) do
|
def handle_event("play_pause", _, socket) do
|
||||||
%{song: song, playing: playing} = socket.assigns
|
%{song: song, playing: playing} = socket.assigns
|
||||||
|
|
||||||
IO.inspect({:play_pause, playing})
|
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
song && playing ->
|
song && playing ->
|
||||||
MediaLibrary.pause_song(song)
|
MediaLibrary.pause_song(song)
|
||||||
|
@ -133,6 +142,15 @@ defmodule LiveBeatsWeb.PlayerLive do
|
||||||
{:noreply, assign(socket, error: false)}
|
{:noreply, assign(socket, error: false)}
|
||||||
end
|
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
|
def handle_info({:pause, _}, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|
@ -140,14 +158,44 @@ defmodule LiveBeatsWeb.PlayerLive do
|
||||||
|> assign(playing: false)}
|
|> assign(playing: false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:play, %Song{} = song, %{began_at: at}}, socket) do
|
def handle_info({:play, %Song{} = song, %{elapsed: elapsed}}, socket) do
|
||||||
{:noreply,
|
{:noreply, play_song(socket, song, elapsed)}
|
||||||
socket
|
|
||||||
|> push_event("play", %{began_at: at, url: Path.join(LiveBeatsWeb.Endpoint.url(), song.mp3_path)})
|
|
||||||
|> assign(song: song, playing: true)}
|
|
||||||
end
|
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")
|
JS.dispatch(js, "js:play_pause", to: "#audio-player")
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -2,8 +2,8 @@ defmodule LiveBeatsWeb.SongLive.Index do
|
||||||
use LiveBeatsWeb, :live_view
|
use LiveBeatsWeb, :live_view
|
||||||
|
|
||||||
alias LiveBeats.{MediaLibrary, MP3Stat}
|
alias LiveBeats.{MediaLibrary, MP3Stat}
|
||||||
alias LiveBeats.MediaLibrary.Song
|
|
||||||
alias LiveBeatsWeb.LayoutComponent
|
alias LiveBeatsWeb.LayoutComponent
|
||||||
|
alias LiveBeatsWeb.SongLive.{SongRowComponent, UploadFormComponent}
|
||||||
|
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
|
@ -27,7 +27,7 @@ defmodule LiveBeatsWeb.SongLive.Index do
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<.live_table
|
<.live_table
|
||||||
module={LiveBeatsWeb.SongLive.SongRow}
|
module={SongRowComponent}
|
||||||
rows={@songs}
|
rows={@songs}
|
||||||
row_id={fn song -> "song-#{song.id}" end}
|
row_id={fn song -> "song-#{song.id}" end}
|
||||||
>
|
>
|
||||||
|
@ -45,19 +45,33 @@ defmodule LiveBeatsWeb.SongLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, _session, socket) do
|
||||||
|
%{current_user: current_user} = socket.assigns
|
||||||
|
|
||||||
if connected?(socket) do
|
if connected?(socket) do
|
||||||
MediaLibrary.subscribe(socket.assigns.current_user)
|
MediaLibrary.subscribe(current_user)
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def handle_params(params, _url, socket) do
|
def handle_params(params, _url, socket) do
|
||||||
{:noreply, socket |> apply_action(socket.assigns.live_action, params) |> maybe_show_modal()}
|
{:noreply, socket |> apply_action(socket.assigns.live_action, params) |> maybe_show_modal()}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("play-song", %{"id" => id}, socket) do
|
def handle_event("play_or_pause", %{"id" => id}, socket) do
|
||||||
MediaLibrary.play_song(id)
|
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}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -67,42 +81,59 @@ defmodule LiveBeatsWeb.SongLive.Index do
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
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)}
|
{:noreply, play_song(socket, song)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:pause, %Song{} = song}, socket) do
|
def handle_info({:pause, %MediaLibrary.Song{} = song}, socket) do
|
||||||
{:noreply, pause_song(socket, song.id)}
|
{:noreply, pause_song(socket, song.id)}
|
||||||
end
|
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
|
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
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
defp play_song(socket, %Song{} = song) do
|
defp play_song(socket, %MediaLibrary.Song{} = song) do
|
||||||
send_update(LiveBeatsWeb.SongLive.SongRow, id: "song-#{song.id}", action: :activate)
|
%{active_id: active_id} = socket.assigns
|
||||||
|
|
||||||
if socket.assigns.active_id do
|
cond do
|
||||||
socket
|
active_id == song.id ->
|
||||||
|> pause_song(socket.assigns.active_id)
|
SongRowComponent.send_status(song.id, :playing)
|
||||||
|> assign(active_id: song.id)
|
socket
|
||||||
else
|
|
||||||
assign(socket, active_id: song.id)
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_show_modal(socket) do
|
defp maybe_show_modal(socket) do
|
||||||
if socket.assigns.live_action in [:new, :edit] do
|
if socket.assigns.live_action in [:new] do
|
||||||
LayoutComponent.show_modal(LiveBeatsWeb.SongLive.UploadFormComponent, %{
|
LayoutComponent.show_modal(UploadFormComponent, %{
|
||||||
|
id: :new,
|
||||||
confirm: {"Save", type: "submit", form: "song-form"},
|
confirm: {"Save", type: "submit", form: "song-form"},
|
||||||
patch_to: Routes.song_index_path(socket, :index),
|
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,
|
song: socket.assigns.song,
|
||||||
current_user: socket.assigns.current_user,
|
title: socket.assigns.page_title,
|
||||||
genres: socket.assigns.genres
|
current_user: socket.assigns.current_user
|
||||||
})
|
})
|
||||||
else
|
else
|
||||||
LayoutComponent.hide_modal()
|
LayoutComponent.hide_modal()
|
||||||
|
@ -114,7 +145,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, "Add Songs")
|
|> assign(:page_title, "Add Songs")
|
||||||
|> assign(:song, %Song{})
|
|> assign(:song, %MediaLibrary.Song{})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp apply_action(socket, :index, _params) do
|
defp apply_action(socket, :index, _params) do
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
defmodule LiveBeatsWeb.SongLive.SongEntryComponent do
|
defmodule LiveBeatsWeb.SongLive.SongEntry do
|
||||||
use LiveBeatsWeb, :live_component
|
use LiveBeatsWeb, :live_component
|
||||||
|
|
||||||
alias LiveBeats.MP3Stat
|
alias LiveBeats.MP3Stat
|
|
@ -1,22 +1,32 @@
|
||||||
defmodule LiveBeatsWeb.SongLive.SongRow do
|
defmodule LiveBeatsWeb.SongLive.SongRowComponent do
|
||||||
use LiveBeatsWeb, :live_component
|
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
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<tr id={@id} class={@class}}>
|
<tr id={@id} class={@class}}>
|
||||||
<%= for {col, i} <- Enum.with_index(@col) do %>
|
<%= for {col, i} <- Enum.with_index(@col) do %>
|
||||||
<td
|
<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"}"}
|
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})}
|
phx-click={JS.push("play_or_pause", value: %{id: @song.id})}
|
||||||
>
|
>
|
||||||
<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 %>
|
<%= if i == 0 do %>
|
||||||
<%= if @active do %>
|
<%= if @status == :playing do %>
|
||||||
<span class="flex pt-1 relative mr-2 w-4">
|
<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>
|
<span class="w-3 h-3 animate-ping bg-purple-400 rounded-full absolute"></span>
|
||||||
<.icon name={:volume_up} class="h-5 w-5 -mt-1 -ml-1"/>
|
<.icon name={:volume_up} class="h-5 w-5 -mt-1 -ml-1"/>
|
||||||
</span>
|
</span>
|
||||||
<% else %>
|
<% end %>
|
||||||
|
<%= if @status == :paused do %>
|
||||||
|
<span class="flex pt-1 relative mr-2 w-4">
|
||||||
|
<.icon name={:volume_up} class="h-5 w-5 -mt-1 -ml-1 text-gray-400"/>
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
<%= if @status == :stopped do %>
|
||||||
<span class="flex relative w-6 -translate-x-1">
|
<span class="flex relative w-6 -translate-x-1">
|
||||||
<.icon name={:play} class="h-5 w-5 text-gray-400"/>
|
<.icon name={:play} class="h-5 w-5 text-gray-400"/>
|
||||||
</span>
|
</span>
|
||||||
|
@ -30,16 +40,8 @@ defmodule LiveBeatsWeb.SongLive.SongRow do
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
def update(%{action: :activate}, socket) do
|
def update(%{action: :send, status: status}, socket) when status in [:playing, :paused, :stopped] do
|
||||||
{:ok, assign(socket, active: true)}
|
{:ok, assign(socket, status: status)}
|
||||||
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
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
def update(assigns, socket) do
|
||||||
|
@ -50,7 +52,7 @@ defmodule LiveBeatsWeb.SongLive.SongRow do
|
||||||
col: assigns.col,
|
col: assigns.col,
|
||||||
class: assigns.class,
|
class: assigns.class,
|
||||||
index: assigns.index,
|
index: assigns.index,
|
||||||
active: false
|
status: :stopped
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -2,7 +2,7 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|
||||||
use LiveBeatsWeb, :live_component
|
use LiveBeatsWeb, :live_component
|
||||||
|
|
||||||
alias LiveBeats.{MediaLibrary, MP3Stat}
|
alias LiveBeats.{MediaLibrary, MP3Stat}
|
||||||
alias LiveBeatsWeb.SongLive.SongEntryComponent
|
alias LiveBeatsWeb.SongLive.SongEntry
|
||||||
|
|
||||||
@max_songs 10
|
@max_songs 10
|
||||||
|
|
||||||
|
@ -37,29 +37,12 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|
||||||
{:noreply, drop_invalid_uploads(socket)}
|
{:noreply, drop_invalid_uploads(socket)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("validate", %{"songs" => songs_params, "_target" => ["songs", _, _]}, socket) do
|
def handle_event("validate", %{"songs" => params, "_target" => ["songs", _, _]}, socket) do
|
||||||
new_socket =
|
{:noreply, apply_params(socket, params, :validate)}
|
||||||
Enum.reduce(songs_params, socket, fn {ref, song_params}, acc ->
|
|
||||||
new_changeset =
|
|
||||||
acc
|
|
||||||
|> get_changeset(ref)
|
|
||||||
|> Ecto.Changeset.apply_changes()
|
|
||||||
|> MediaLibrary.change_song(song_params)
|
|
||||||
|> Map.put(:action, :validate)
|
|
||||||
|
|
||||||
update_changeset(acc, new_changeset, ref)
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:noreply, new_socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp consume_entry(socket, ref, store_func) when is_function(store_func) do
|
def handle_event("save", %{"songs" => params}, socket) do
|
||||||
{entries, []} = uploaded_entries(socket, :mp3)
|
socket = apply_params(socket, params, :insert)
|
||||||
entry = Enum.find(entries, fn entry -> entry.ref == ref end)
|
|
||||||
consume_uploaded_entry(socket, entry, fn meta -> {:ok, store_func.(meta.path)} end)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("save", %{"songs" => song_params}, socket) do
|
|
||||||
%{current_user: current_user} = socket.assigns
|
%{current_user: current_user} = socket.assigns
|
||||||
changesets = socket.assigns.changesets
|
changesets = socket.assigns.changesets
|
||||||
|
|
||||||
|
@ -75,6 +58,25 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp consume_entry(socket, ref, store_func) when is_function(store_func) do
|
||||||
|
{entries, []} = uploaded_entries(socket, :mp3)
|
||||||
|
entry = Enum.find(entries, fn entry -> entry.ref == ref end)
|
||||||
|
consume_uploaded_entry(socket, entry, fn meta -> {:ok, store_func.(meta.path)} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_params(socket, params, action) when action in [:validate, :insert] do
|
||||||
|
Enum.reduce(params, socket, fn {ref, song_params}, acc ->
|
||||||
|
new_changeset =
|
||||||
|
acc
|
||||||
|
|> get_changeset(ref)
|
||||||
|
|> Ecto.Changeset.apply_changes()
|
||||||
|
|> MediaLibrary.change_song(song_params)
|
||||||
|
|> Map.put(:action, action)
|
||||||
|
|
||||||
|
update_changeset(acc, new_changeset, ref)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp get_changeset(socket, entry_ref) do
|
defp get_changeset(socket, entry_ref) do
|
||||||
case Enum.find(socket.assigns.changesets, fn {ref, _changeset} -> ref === entry_ref end) do
|
case Enum.find(socket.assigns.changesets, fn {ref, _changeset} -> ref === entry_ref end) do
|
||||||
{^entry_ref, changeset} -> changeset
|
{^entry_ref, changeset} -> changeset
|
||||||
|
@ -102,27 +104,30 @@ defmodule LiveBeatsWeb.SongLive.UploadFormComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
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(SongEntry, id: entry.ref, progress: entry.progress)
|
||||||
lv = self()
|
|
||||||
|
|
||||||
if entry.done? do
|
if entry.done? do
|
||||||
consume_uploaded_entry(socket, entry, fn %{path: path} ->
|
async_calculate_duration(socket, entry)
|
||||||
Task.Supervisor.start_child(LiveBeats.TaskSupervisor, fn ->
|
|
||||||
result = LiveBeats.MP3Stat.parse(path)
|
|
||||||
|
|
||||||
send_update(lv, __MODULE__,
|
|
||||||
id: socket.assigns.id,
|
|
||||||
action: {:duration, entry.ref, result}
|
|
||||||
)
|
|
||||||
end)
|
|
||||||
|
|
||||||
{:postpone, :ok}
|
|
||||||
end)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, put_new_changeset(socket, entry)}
|
{:noreply, put_new_changeset(socket, entry)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp async_calculate_duration(socket, %Phoenix.LiveView.UploadEntry{} = entry) do
|
||||||
|
lv = self()
|
||||||
|
|
||||||
|
consume_uploaded_entry(socket, entry, fn %{path: path} ->
|
||||||
|
Task.Supervisor.start_child(LiveBeats.TaskSupervisor, fn ->
|
||||||
|
send_update(lv, __MODULE__,
|
||||||
|
id: socket.assigns.id,
|
||||||
|
action: {:duration, entry.ref, LiveBeats.MP3Stat.parse(path)}
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:postpone, :ok}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp file_error(%{kind: :dropped} = assigns), do: ~H|dropped (exceeds limit of 10 files)|
|
defp file_error(%{kind: :dropped} = assigns), do: ~H|dropped (exceeds limit of 10 files)|
|
||||||
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|
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<h2><%= @title %></h2>
|
<h2><%= @title %></h2>
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
let={f}
|
|
||||||
for={:songs}
|
for={:songs}
|
||||||
id="song-form"
|
id="song-form"
|
||||||
class="space-y-8"
|
class="space-y-8"
|
||||||
|
@ -13,7 +12,7 @@
|
||||||
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
|
<div class="space-y-8 divide-y divide-gray-200 sm:space-y-5">
|
||||||
<div class="space-y-2 sm:space-y-2">
|
<div class="space-y-2 sm:space-y-2">
|
||||||
<%= for {ref, changeset} <- @changesets do %>
|
<%= for {ref, changeset} <- @changesets do %>
|
||||||
<.live_component id={ref} module={SongEntryComponent} changeset={changeset} />
|
<.live_component id={ref} module={SongEntry} changeset={changeset} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- upload -->
|
<!-- upload -->
|
||||||
|
|
|
@ -307,7 +307,9 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
|
<main class="flex-1 relative z-0 overflow-y-auto focus:outline-none">
|
||||||
<%= live_render(@conn, LiveBeatsWeb.PlayerLive, session: %{}) %>
|
<%= if @current_user do %>
|
||||||
|
<%= live_render(@conn, LiveBeatsWeb.PlayerLive, session: %{}) %>
|
||||||
|
<% end %>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -4,11 +4,14 @@ defmodule LiveBeats.Repo.Migrations.CreateSongs do
|
||||||
def change do
|
def change do
|
||||||
create table(:songs) do
|
create table(:songs) do
|
||||||
add :album_artist, :string
|
add :album_artist, :string
|
||||||
add :artist, :string
|
add :artist, :string, null: false
|
||||||
add :duration, :integer
|
add :duration, :integer, default: 0, null: false
|
||||||
add :title, :string
|
add :status, :integer, null: false, default: 1
|
||||||
add :mp3_path, :string
|
add :played_at, :utc_datetime
|
||||||
add :mp3_filename, :string
|
add :paused_at, :utc_datetime
|
||||||
|
add :title, :string, null: false
|
||||||
|
add :mp3_path, :string, null: false
|
||||||
|
add :mp3_filepath, :string, null: false
|
||||||
add :date_recorded, :naive_datetime
|
add :date_recorded, :naive_datetime
|
||||||
add :date_released, :naive_datetime
|
add :date_released, :naive_datetime
|
||||||
add :user_id, references(:users, on_delete: :nothing)
|
add :user_id, references(:users, on_delete: :nothing)
|
||||||
|
@ -20,5 +23,6 @@ defmodule LiveBeats.Repo.Migrations.CreateSongs do
|
||||||
create unique_index(:songs, [:user_id, :title, :artist])
|
create unique_index(:songs, [:user_id, :title, :artist])
|
||||||
create index(:songs, [:user_id])
|
create index(:songs, [:user_id])
|
||||||
create index(:songs, [:genre_id])
|
create index(:songs, [:genre_id])
|
||||||
|
create index(:songs, [:status])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,11 +14,15 @@
|
||||||
# {:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
|
# {:ok, _} = LiveBeats.MediaLibrary.create_genre(%{title: title})
|
||||||
# end
|
# end
|
||||||
|
|
||||||
for i <- 1..200 do
|
# for i <- 1..200 do
|
||||||
{:ok, _} =
|
# filename = Ecto.UUID.generate()
|
||||||
LiveBeats.MediaLibrary.create_song(%{
|
|
||||||
artist: "Bonobo",
|
# {:ok, _} =
|
||||||
title: "Black Sands #{i}",
|
# LiveBeats.Repo.insert(%LiveBeats.MediaLibrary.Song{
|
||||||
duration: 180_000
|
# artist: "Bonobo",
|
||||||
})
|
# title: "Black Sands #{i}",
|
||||||
end
|
# duration: 180_000,
|
||||||
|
# mp3_filename: filename,
|
||||||
|
# mp3_path: "uploads/songs/#{filename}"
|
||||||
|
# })
|
||||||
|
# end
|
||||||
|
|
Loading…
Reference in a new issue