Refactor Release component to take release_id

This commit is contained in:
Claudio Ortolina
2026-04-23 08:03:36 +01:00
parent 0d1f580379
commit 4c48bda7b2
5 changed files with 274 additions and 181 deletions
+162 -170
View File
@@ -20,16 +20,14 @@ defmodule MusicLibraryWeb.Components.Release do
|> assign(:release_with_tracks, AsyncResult.loading()) |> assign(:release_with_tracks, AsyncResult.loading())
|> assign(:already_scrobbled, false) |> assign(:already_scrobbled, false)
|> assign(:selected_tracks, MapSet.new()) |> assign(:selected_tracks, MapSet.new())
|> assign(:timezone, MusicLibrary.default_timezone())
|> assign(:finished_at, nil)
|> assign(:form, to_form(%{"finished_at" => nil}, as: :release))
|> assign(:pending_form_params, nil)} |> assign(:pending_form_params, nil)}
end end
@impl true @impl true
def update(%{record: record} = assigns, socket) do def update(%{release_id: release_id} = assigns, socket) do
socket = assign(socket, assigns) socket = assign(socket, assigns)
current_time = DateTime.utc_now() |> DateTime.shift_zone!(socket.assigns.timezone) timezone = socket.assigns.timezone
current_time = DateTime.utc_now() |> DateTime.shift_zone!(timezone)
{:ok, {:ok,
socket socket
@@ -37,7 +35,7 @@ defmodule MusicLibraryWeb.Components.Release do
|> assign(:form, to_form(%{"finished_at" => DateTime.to_naive(current_time)}, as: :release)) |> assign(:form, to_form(%{"finished_at" => DateTime.to_naive(current_time)}, as: :release))
|> assign(:release_with_tracks, AsyncResult.loading()) |> assign(:release_with_tracks, AsyncResult.loading())
|> start_async(:release_with_tracks, fn -> |> start_async(:release_with_tracks, fn ->
load_release_with_tracks(record.selected_release_id) load_release_with_tracks(release_id)
end)} end)}
end end
@@ -116,156 +114,149 @@ defmodule MusicLibraryWeb.Components.Release do
def render(assigns) do def render(assigns) do
~H""" ~H"""
<div> <div>
<.sheet <.form
:if={@record.selected_release_id} for={@form}
id={@sheet_id} id={"#{@sheet_id}-form"}
placement="right" phx-target={@myself}
class="flex min-w-xs flex-col overflow-hidden p-0 sm:min-w-sm" phx-change="validate"
phx-auto-recover="recover_form"
class="flex min-h-0 flex-1 flex-col"
> >
<.form <div class="min-h-0 flex-1 overflow-y-auto px-6 pt-6 pb-4">
for={@form} <input type="hidden" name="release[selected_tracks][]" value="" />
id={"#{@sheet_id}-form"} <input type="hidden" name="release[toggle_medium][]" value="" />
phx-target={@myself}
phx-change="validate"
phx-auto-recover="recover_form"
class="flex min-h-0 flex-1 flex-col"
>
<div class="min-h-0 flex-1 overflow-y-auto px-6 pt-6 pb-4">
<input type="hidden" name="release[selected_tracks][]" value="" />
<input type="hidden" name="release[toggle_medium][]" value="" />
<div class="mt-6 flex flex-wrap items-start justify-between gap-3"> <div class="mt-6 flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<h2 class="truncate text-lg font-semibold text-zinc-900 dark:text-zinc-100"> <h2 class="truncate text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{@record.title} {header_title(@release_with_tracks)}
</h2> </h2>
<p <p
:if={artist_names = header_subtitle(@record)} :if={artist_names = header_subtitle(@release_with_tracks)}
class="truncate text-xs text-zinc-500 dark:text-zinc-400" class="truncate text-xs text-zinc-500 dark:text-zinc-400"
> >
{artist_names} {artist_names}
</p> </p>
</div>
<div class="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<.date_time_picker
:if={@can_scrobble?}
field={@form[:finished_at]}
size="sm"
display_format="%b %-d, %H:%M"
time_format="24"
placeholder={gettext("Now")}
class="flex-1 sm:flex-initial"
>
<:inner_prefix class="pl-2 text-zinc-500 dark:text-zinc-400">
{gettext("Finished at")}
</:inner_prefix>
<:outer_suffix class="pr-2">
<.button
size="sm"
type="button"
phx-click="reset_to_now"
phx-target={@myself}
>
{gettext("Now")}
</.button>
</:outer_suffix>
</.date_time_picker>
<.button
:if={@can_scrobble? && @release_with_tracks.ok?}
type="button"
variant="solid"
size="sm"
disabled={@already_scrobbled}
phx-click="scrobble_release"
phx-target={@myself}
phx-disable-with={gettext("Scrobbling...")}
>
<.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" />
<span class="hidden sm:inline">{gettext("Scrobble release")}</span>
<span class="sm:hidden">{gettext("Release")}</span>
</.button>
<.dropdown id={"#{@sheet_id}-release-actions"} placement="bottom-end">
<:toggle>
<.button type="button" variant="outline" size="sm">
<span class="sr-only">{gettext("More actions")}</span>
<.icon
name="hero-ellipsis-vertical"
class="icon"
aria-hidden="true"
data-slot="icon"
/>
</.button>
</:toggle>
<.focus_wrap id={"#{@sheet_id}-release-actions-focus-wrap"}>
<.dropdown_link
:if={@release_with_tracks.ok?}
phx-click="print_tracklist"
phx-target={@myself}
>
<.icon name="hero-printer" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Print tracklist")}
</.dropdown_link>
<.dropdown_link :if={!@can_scrobble?} href={LastFm.auth_url()}>
<.icon name="hero-link" class="icon" aria-hidden="true" data-slot="icon" />
{gettext("Connect Last.fm")}
</.dropdown_link>
</.focus_wrap>
</.dropdown>
</div>
</div> </div>
<div class="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<div :if={@release_with_tracks} class="mt-4 space-y-4"> <.date_time_picker
<.async_result :let={release_with_tracks} assign={@release_with_tracks}> :if={@can_scrobble?}
<:loading> field={@form[:finished_at]}
<div class="mt-48 flex items-center justify-center"> size="sm"
<span class="sr-only">{gettext("Loading release with tracks")}</span> display_format="%b %-d, %H:%M"
<.loading /> time_format="24"
</div> placeholder={gettext("Now")}
</:loading> class="flex-1 sm:flex-initial"
<:failed :let={_failure}> >
<div class="mt-4 text-sm/5 text-zinc-500 dark:text-zinc-400"> <:inner_prefix class="pl-2 text-zinc-500 dark:text-zinc-400">
{gettext("Finished at")}
</:inner_prefix>
<:outer_suffix class="pr-2">
<.button
size="sm"
type="button"
phx-click="reset_to_now"
phx-target={@myself}
>
{gettext("Now")}
</.button>
</:outer_suffix>
</.date_time_picker>
<.button
:if={@can_scrobble? && @release_with_tracks.ok?}
type="button"
variant="solid"
size="sm"
disabled={@already_scrobbled}
phx-click="scrobble_release"
phx-target={@myself}
phx-disable-with={gettext("Scrobbling...")}
>
<.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" />
<span class="hidden sm:inline">{gettext("Scrobble release")}</span>
<span class="sm:hidden">{gettext("Release")}</span>
</.button>
<.dropdown id={"#{@sheet_id}-release-actions"} placement="bottom-end">
<:toggle>
<.button type="button" variant="outline" size="sm">
<span class="sr-only">{gettext("More actions")}</span>
<.icon <.icon
name="hero-exclamation-triangle" name="hero-ellipsis-vertical"
class="size-5" class="icon"
aria-hidden="true" aria-hidden="true"
data-slot="icon" data-slot="icon"
/> />
{gettext("Error loading tracks")} </.button>
<.button </:toggle>
type="button" <.focus_wrap id={"#{@sheet_id}-release-actions-focus-wrap"}>
variant="ghost" <.dropdown_link
size="xs" :if={@show_print? && @release_with_tracks.ok?}
phx-click={JS.push("load_release_tracks", target: @myself)} phx-click="print_tracklist"
class="ml-2 cursor-pointer" phx-target={@myself}
> >
{gettext("Retry")} <.icon name="hero-printer" class="icon" aria-hidden="true" data-slot="icon" />
</.button> {gettext("Print tracklist")}
</div> </.dropdown_link>
</:failed> <.dropdown_link :if={!@can_scrobble?} href={LastFm.auth_url()}>
<.medium <.icon name="hero-link" class="icon" aria-hidden="true" data-slot="icon" />
:for={medium <- release_with_tracks.media} {gettext("Connect Last.fm")}
can_scrobble?={@can_scrobble?} </.dropdown_link>
already_scrobbled={@already_scrobbled} </.focus_wrap>
medium={medium} </.dropdown>
release_artists={release_with_tracks.artists}
media_count={MusicBrainz.Release.media_count(release_with_tracks)}
selected_tracks={@selected_tracks}
myself={@myself}
record={@record}
/>
</.async_result>
</div> </div>
</div> </div>
<.selection_bar <div :if={@release_with_tracks} class="mt-4 space-y-4">
:if={@can_scrobble? && @release_with_tracks.ok? && MapSet.size(@selected_tracks) > 0} <.async_result :let={release_with_tracks} assign={@release_with_tracks}>
release={@release_with_tracks.result} <:loading>
selected_tracks={@selected_tracks} <div class="mt-48 flex items-center justify-center">
already_scrobbled={@already_scrobbled} <span class="sr-only">{gettext("Loading release with tracks")}</span>
myself={@myself} <.loading />
/> </div>
</.form> </:loading>
</.sheet> <:failed :let={_failure}>
<div class="mt-4 text-sm/5 text-zinc-500 dark:text-zinc-400">
<.icon
name="hero-exclamation-triangle"
class="size-5"
aria-hidden="true"
data-slot="icon"
/>
{gettext("Error loading tracks")}
<.button
type="button"
variant="ghost"
size="xs"
phx-click={JS.push("load_release_tracks", target: @myself)}
class="ml-2 cursor-pointer"
>
{gettext("Retry")}
</.button>
</div>
</:failed>
<.medium
:for={medium <- release_with_tracks.media}
can_scrobble?={@can_scrobble?}
already_scrobbled={@already_scrobbled}
medium={medium}
release_artists={release_with_tracks.artists}
media_count={MusicBrainz.Release.media_count(release_with_tracks)}
selected_tracks={@selected_tracks}
myself={@myself}
show_print?={@show_print?}
/>
</.async_result>
</div>
</div>
<.selection_bar
:if={@can_scrobble? && @release_with_tracks.ok? && MapSet.size(@selected_tracks) > 0}
release={@release_with_tracks.result}
selected_tracks={@selected_tracks}
already_scrobbled={@already_scrobbled}
myself={@myself}
/>
</.form>
</div> </div>
""" """
end end
@@ -277,7 +268,7 @@ defmodule MusicLibraryWeb.Components.Release do
attr :already_scrobbled, :boolean, required: true attr :already_scrobbled, :boolean, required: true
attr :selected_tracks, :any, required: true attr :selected_tracks, :any, required: true
attr :myself, :any, required: true attr :myself, :any, required: true
attr :record, :any, default: nil attr :show_print?, :boolean, required: true
def medium(assigns) do def medium(assigns) do
~H""" ~H"""
@@ -317,7 +308,7 @@ defmodule MusicLibraryWeb.Components.Release do
<span class="sr-only sm:hidden">{medium_scrobble_label(@medium.format)}</span> <span class="sr-only sm:hidden">{medium_scrobble_label(@medium.format)}</span>
</.button> </.button>
<.dropdown <.dropdown
:if={@record} :if={@show_print?}
id={"medium-actions-#{@medium.number}"} id={"medium-actions-#{@medium.number}"}
placement="bottom-end" placement="bottom-end"
> >
@@ -435,7 +426,7 @@ defmodule MusicLibraryWeb.Components.Release do
) )
~H""" ~H"""
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-white px-6 py-3 shadow-[0_-4px_14px_rgba(0,0,0,0.06)] dark:border-zinc-700 dark:bg-zinc-900"> <div class="sticky bottom-0 z-10 flex flex-wrap items-center justify-between gap-3 border-t border-zinc-200 bg-white px-6 py-3 shadow-[0_-4px_14px_rgba(0,0,0,0.06)] dark:border-zinc-700 dark:bg-zinc-900">
<div class="min-w-0 flex-1 leading-tight"> <div class="min-w-0 flex-1 leading-tight">
<p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100"> <p class="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{ngettext("%{count} track selected", "%{count} tracks selected", @count, count: @count)} {ngettext("%{count} track selected", "%{count} tracks selected", @count, count: @count)}
@@ -618,11 +609,10 @@ defmodule MusicLibraryWeb.Components.Release do
def handle_event("print_tracklist", _params, socket) when release_loaded?(socket.assigns) do def handle_event("print_tracklist", _params, socket) when release_loaded?(socket.assigns) do
release = socket.assigns.release_with_tracks.result release = socket.assigns.release_with_tracks.result
record = socket.assigns.record
case TracklistPdf.generate(release) do case TracklistPdf.generate(release) do
{:ok, pdf_binary} -> {:ok, pdf_binary} ->
filename = "#{record.title} - Tracklist.pdf" filename = "#{release.title} - Tracklist.pdf"
{:noreply, {:noreply,
push_event(socket, "music_library:download", %{ push_event(socket, "music_library:download", %{
@@ -651,12 +641,11 @@ defmodule MusicLibraryWeb.Components.Release do
def handle_event("print_medium_tracklist", %{"medium-number" => number}, socket) def handle_event("print_medium_tracklist", %{"medium-number" => number}, socket)
when release_loaded?(socket.assigns) do when release_loaded?(socket.assigns) do
release = socket.assigns.release_with_tracks.result release = socket.assigns.release_with_tracks.result
record = socket.assigns.record
{number, ""} = Integer.parse(number) {number, ""} = Integer.parse(number)
case TracklistPdf.generate_medium(release, number) do case TracklistPdf.generate_medium(release, number) do
{:ok, pdf_binary} -> {:ok, pdf_binary} ->
filename = "#{record.title} - Disc #{number} - Tracklist.pdf" filename = "#{release.title} - Disc #{number} - Tracklist.pdf"
{:noreply, {:noreply,
push_event(socket, "music_library:download", %{ push_event(socket, "music_library:download", %{
@@ -683,7 +672,7 @@ defmodule MusicLibraryWeb.Components.Release do
end end
def handle_event("load_release_tracks", _params, socket) do def handle_event("load_release_tracks", _params, socket) do
selected_release_id = socket.assigns.record.selected_release_id selected_release_id = socket.assigns.release_id
{:noreply, {:noreply,
socket socket
@@ -753,15 +742,6 @@ defmodule MusicLibraryWeb.Components.Release do
end) end)
end end
@spec scrobble_button_label(MapSet.t()) :: String.t()
def scrobble_button_label(selected_tracks) do
if MapSet.size(selected_tracks) > 0 do
gettext("Scrobble selected tracks")
else
gettext("Scrobble release")
end
end
defp medium_duration(medium) do defp medium_duration(medium) do
medium medium
|> MusicBrainz.Release.medium_duration() |> MusicBrainz.Release.medium_duration()
@@ -776,16 +756,28 @@ defmodule MusicLibraryWeb.Components.Release do
end end
end end
defp header_subtitle(%{artists: artists}) when is_list(artists) and artists != [] do @spec header_title(AsyncResult.t() | term()) :: String.t()
artists defp header_title(%AsyncResult{ok?: true, result: release}), do: release.title
|> Enum.map_join(", ", & &1.name) defp header_title(_), do: ""
|> case do
"" -> nil @spec header_subtitle(AsyncResult.t() | term()) :: String.t() | nil
names -> names defp header_subtitle(%AsyncResult{ok?: true, result: release}) do
case release.artists do
[] ->
nil
artists ->
artists
|> Enum.map_join(fn artist -> artist.name <> (artist.joinphrase || "") end)
|> String.trim()
|> case do
"" -> nil
names -> names
end
end end
end end
defp header_subtitle(_record), do: nil defp header_subtitle(_), do: nil
@spec selected_tracks_summary(MusicBrainz.Release.t(), MapSet.t()) :: @spec selected_tracks_summary(MusicBrainz.Release.t(), MapSet.t()) ::
{non_neg_integer(), non_neg_integer(), non_neg_integer()} {non_neg_integer(), non_neg_integer(), non_neg_integer()}
@@ -292,13 +292,21 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
<.record_debug_sheet record={@record} embedding_text={@embedding_text} /> <.record_debug_sheet record={@record} embedding_text={@embedding_text} />
<.live_component <.sheet
id="release-with-tracks" :if={@record.selected_release_id}
sheet_id="release-with-tracks-sheet" id="release-with-tracks-sheet"
module={MusicLibraryWeb.Components.Release} placement="right"
record={@record} class="flex min-w-xs flex-col overflow-hidden p-0 sm:min-w-sm"
timezone={@timezone} >
/> <.live_component
id="release-with-tracks"
sheet_id="release-with-tracks-sheet"
module={MusicLibraryWeb.Components.Release}
release_id={@record.selected_release_id}
show_print?={true}
timezone={@timezone}
/>
</.sheet>
<.live_component <.live_component
id="record-notes" id="record-notes"
@@ -1,7 +1,7 @@
defmodule MusicLibraryWeb.ScrobbleLive.Show do defmodule MusicLibraryWeb.ScrobbleLive.Show do
use MusicLibraryWeb, :live_view use MusicLibraryWeb, :live_view
import(MusicLibraryWeb.Components.Release, only: [medium: 1, scrobble_button_label: 1]) import(MusicLibraryWeb.Components.Release, only: [medium: 1])
import MusicLibraryWeb.RecordComponents, only: [country_label: 1] import MusicLibraryWeb.RecordComponents, only: [country_label: 1]
alias MusicLibrary.ScrobbleActivity alias MusicLibrary.ScrobbleActivity
@@ -103,7 +103,11 @@ defmodule MusicLibraryWeb.ScrobbleLive.Show do
else: "scrobble_release" else: "scrobble_release"
} }
> >
<span class="sr-only">{scrobble_button_label(@selected_tracks)}</span> <span class="sr-only">
{if MapSet.size(@selected_tracks) > 0,
do: gettext("Scrobble selected tracks"),
else: gettext("Scrobble release")}
</span>
<.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" />
</.button> </.button>
</div> </div>
@@ -117,6 +121,7 @@ defmodule MusicLibraryWeb.ScrobbleLive.Show do
selected_tracks={@selected_tracks} selected_tracks={@selected_tracks}
media_count={MusicBrainz.Release.media_count(@release)} media_count={MusicBrainz.Release.media_count(@release)}
myself={nil} myself={nil}
show_print?={false}
/> />
</.form> </.form>
</div> </div>
+2 -1
View File
@@ -666,11 +666,12 @@ msgstr ""
#: lib/music_library_web/components/release.ex #: lib/music_library_web/components/release.ex
#: lib/music_library_web/live/collection_live/show.ex #: lib/music_library_web/live/collection_live/show.ex
#: lib/music_library_web/live/scrobble_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Scrobble release" msgid "Scrobble release"
msgstr "" msgstr ""
#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Scrobble selected tracks" msgid "Scrobble selected tracks"
msgstr "" msgstr ""
@@ -12,7 +12,7 @@ defmodule MusicLibraryWeb.Components.ReleaseTest do
import MusicLibrary.Fixtures.Records import MusicLibrary.Fixtures.Records
import Phoenix.LiveViewTest, import Phoenix.LiveViewTest,
only: [element: 2, render_async: 1, render_change: 2, render_click: 1] only: [element: 2, render: 1, render_async: 1, render_change: 2, render_click: 1]
alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures
alias MusicLibrary.Secrets alias MusicLibrary.Secrets
@@ -288,3 +288,90 @@ defmodule MusicLibraryWeb.Components.ReleaseTest do
end end
end end
end end
defmodule ReleaseComponentHost do
@moduledoc false
use MusicLibraryWeb, :live_view
@impl true
def mount(_params, session, socket) do
{:ok,
assign(socket,
release_id: session["release_id"],
show_print?: session["show_print?"],
timezone: "UTC"
)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.live_component
id="host-release"
module={MusicLibraryWeb.Components.Release}
release_id={@release_id}
show_print?={@show_print?}
sheet_id="host-sheet"
timezone={@timezone}
/>
</div>
"""
end
end
defmodule MusicLibraryWeb.Components.ReleaseTest.ShowPrintTest do
use MusicLibraryWeb.ConnCase, async: false
import Phoenix.LiveViewTest,
only: [render: 1, render_async: 1]
alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures
alias Req.Test
defp stub_musicbrainz_release(_) do
Test.stub(MusicBrainz.API, fn conn ->
case conn.request_path do
"/ws/2/release/" <> _id ->
Test.json(conn, ReleaseFixtures.release_with_media(:marbles))
_ ->
Test.json(conn, %{})
end
end)
:ok
end
describe "show_print? assign" do
setup [:stub_musicbrainz_release]
test "true renders Print tracklist dropdown entries", %{conn: conn} do
{:ok, view, _html} =
Phoenix.LiveViewTest.live_isolated(conn, ReleaseComponentHost,
session: %{
"release_id" => ReleaseFixtures.release_id(:marbles),
"show_print?" => true
}
)
render_async(view)
assert render(view) =~ "Print tracklist"
end
test "false hides Print tracklist dropdown entries", %{conn: conn} do
{:ok, view, _html} =
Phoenix.LiveViewTest.live_isolated(conn, ReleaseComponentHost,
session: %{
"release_id" => ReleaseFixtures.release_id(:marbles),
"show_print?" => false
}
)
render_async(view)
refute render(view) =~ "Print tracklist"
end
end
end