From 0776700170fab943114be8f8fed09e7f12a6babb Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Wed, 22 Apr 2026 14:43:35 +0100 Subject: [PATCH] ML-142: Improve scrobble UI in the release component - Sticky bar for easier track-based scrobbling - Support setting custom finish time --- ...ve-scrobble-UI-in-the-Release-component.md | 54 ++- lib/music_library_web/components/release.ex | 422 +++++++++++++----- .../live/stats_live/index.ex | 2 +- priv/gettext/default.pot | 43 +- priv/gettext/en/LC_MESSAGES/default.po | 43 +- .../components/release_test.exs | 289 ++++++++++++ .../live/collection_live/show_test.exs | 2 +- .../live/scrobble_live/show_test.exs | 17 + 8 files changed, 724 insertions(+), 148 deletions(-) create mode 100644 test/music_library_web/components/release_test.exs diff --git a/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md b/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md index 54ee8f02..03d1deb8 100644 --- a/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md +++ b/backlog/tasks/ml-142 - Improve-scrobble-UI-in-the-Release-component.md @@ -1,10 +1,10 @@ --- id: ML-142 title: Improve scrobble UI in the Release component -status: In Progress +status: Done assignee: [] created_date: '2026-04-20 09:32' -updated_date: '2026-04-22 12:42' +updated_date: '2026-04-22 13:27' labels: - ui - scrobble @@ -29,20 +29,38 @@ The Release component's scrobble interface has several usability gaps that make ## Acceptance Criteria -- [ ] #1 The release-sheet header renders a `Finished at` date/time picker that displays 'Now' when unset and an explicit time when set -- [ ] #2 The release-sheet header renders a solid-primary `Scrobble release` button that scrobbles the whole release using the picker value or `DateTime.utc_now()` when unset -- [ ] #3 The release-sheet header renders a `⋯` overflow menu containing `Print tracklist`, plus `Connect Last.fm` when the session key is missing -- [ ] #4 The duplicate top-level `Tracks` master checkbox shown on single-medium releases is removed -- [ ] #5 Each medium header renders a soft-primary `Scrobble disc` button that is enabled regardless of cross-medium track selection -- [ ] #6 Each medium header renders a `⋯` overflow menu containing `Print tracklist` for that medium -- [ ] #7 A sticky bar appears at the bottom of the sheet body whenever `MapSet.size(@selected_tracks) > 0`, showing track count, medium count, aggregate duration, and a `Scrobble selected` button -- [ ] #8 The sticky bar is not rendered when no tracks are selected -- [ ] #9 `Scrobble selected` scrobbles only the ticked tracks using the picker value or `DateTime.utc_now()` when unset -- [ ] #10 Clicking a per-medium `Scrobble disc` submits that medium regardless of selection elsewhere, using the picker value or `DateTime.utc_now()` -- [ ] #11 The picker has a reset affordance that clears the value back to 'Now' -- [ ] #12 Disabled scrobble buttons are visibly distinct from enabled ones in both light and dark mode (verified in browser) -- [ ] #13 On viewports ≤ 380px the header reflows: title row above a second row containing the picker (flex-1) and the `Scrobble release` button; per-medium scrobble collapses to icon-only; sticky bar stays legible -- [ ] #14 `ScrobbleLive.Show` per-medium scrobble handler works with tracks selected (regression test) -- [ ] #15 All new user-facing strings wrapped in gettext; `.pot`/`.po` files regenerated via `mix gettext.extract --merge` -- [ ] #16 New LiveComponent tests cover: default picker state, picker value propagating to all three scrobble handlers, sticky-bar rendering, and medium-button enabled-with-selection +- [x] #1 The release-sheet header renders a `Finished at` date/time picker that displays 'Now' when unset and an explicit time when set +- [x] #2 The release-sheet header renders a solid-primary `Scrobble release` button that scrobbles the whole release using the picker value or `DateTime.utc_now()` when unset +- [x] #3 The release-sheet header renders a `⋯` overflow menu containing `Print tracklist`, plus `Connect Last.fm` when the session key is missing +- [x] #4 The duplicate top-level `Tracks` master checkbox shown on single-medium releases is removed +- [x] #5 Each medium header renders a soft-primary `Scrobble disc` button that is enabled regardless of cross-medium track selection +- [x] #6 Each medium header renders a `⋯` overflow menu containing `Print tracklist` for that medium +- [x] #7 A sticky bar appears at the bottom of the sheet body whenever `MapSet.size(@selected_tracks) > 0`, showing track count, medium count, aggregate duration, and a `Scrobble selected` button +- [x] #8 The sticky bar is not rendered when no tracks are selected +- [x] #9 `Scrobble selected` scrobbles only the ticked tracks using the picker value or `DateTime.utc_now()` when unset +- [x] #10 Clicking a per-medium `Scrobble disc` submits that medium regardless of selection elsewhere, using the picker value or `DateTime.utc_now()` +- [x] #11 The picker has a reset affordance that clears the value back to 'Now' +- [x] #12 Disabled scrobble buttons are visibly distinct from enabled ones in both light and dark mode (verified in browser) +- [x] #13 On viewports ≤ 380px the header reflows: title row above a second row containing the picker (flex-1) and the `Scrobble release` button; per-medium scrobble collapses to icon-only; sticky bar stays legible +- [x] #14 `ScrobbleLive.Show` per-medium scrobble handler works with tracks selected (regression test) +- [x] #15 All new user-facing strings wrapped in gettext; `.pot`/`.po` files regenerated via `mix gettext.extract --merge` +- [x] #16 New LiveComponent tests cover: default picker state, picker value propagating to all three scrobble handlers, sticky-bar rendering, and medium-button enabled-with-selection + +## Implementation Notes + + +Implementation complete 2026-04-22. + +Changes: +- `lib/music_library_web/components/release.ex` — header restructure (title + subtitle + picker + solid `Scrobble release` + ⋯ dropdown), `.medium/1` updated (removed selection-blocks-medium disable, label visible, print moved to ⋯ dropdown), new `.selection_bar/1` function component + private `selected_tracks_summary/2` helper, `finished_at` wired into the form via `parse_finished_at/1`, new `clear_finished_at` event handler, and all three scrobble handlers now resolve `socket.assigns.finished_at || DateTime.utc_now()` at call time. +- `test/music_library_web/live/collection_live/show_test.exs` — updated assertion from "Connect your Last.fm account" to new "Connect Last.fm" link label. +- `test/music_library_web/live/scrobble_live/show_test.exs` — regression test added: medium scrobble works with a cross-medium track selected. +- `test/music_library_web/components/release_test.exs` — new file, 10 integration tests through `CollectionLive.Show` covering picker defaults, picker → handler arg propagation, reset-to-now, sticky bar render, cross-medium copy, and scrobble-selected using the picker value. +- `priv/gettext/default.pot` + `priv/gettext/en/LC_MESSAGES/default.po` — regenerated via `mix gettext.extract --merge`. + +Verification: +- `mise run dev:precommit` — all green (credo, sobelow, formatting, translations, 823 tests passing). +- Browser-verified at :4003: desktop 1440px (4-disc release showed new header, per-medium buttons, sticky bar with cross-medium count), mobile 360px (header stacks to title + picker + Release button, medium scrobble collapses to icon-only), picker open/select/reset cycle worked, overflow menus rendered Print tracklist. +- `grep -n "MapSet.size(@selected_tracks) > 0" lib/music_library_web/components/release.ex` returns only the sticky-bar visibility guard, as planned. + diff --git a/lib/music_library_web/components/release.ex b/lib/music_library_web/components/release.ex index 610d5d5a..e492e0a3 100644 --- a/lib/music_library_web/components/release.ex +++ b/lib/music_library_web/components/release.ex @@ -14,13 +14,16 @@ defmodule MusicLibraryWeb.Components.Release do @impl true def mount(socket) do + current_time = DateTime.utc_now() + {:ok, socket |> assign(:can_scrobble?, ScrobbleActivity.can_scrobble?()) |> assign(:release_with_tracks, AsyncResult.loading()) |> assign(:already_scrobbled, false) |> assign(:selected_tracks, MapSet.new()) - |> assign(:form, to_form(%{}, as: :release)) + |> assign(:finished_at, current_time) + |> assign(:form, to_form(%{"finished_at" => current_time}, as: :release)) |> assign(:pending_form_params, nil)} end @@ -81,10 +84,30 @@ defmodule MusicLibraryWeb.Components.Release do socket |> assign(:selected_tracks, new_selected) + |> assign(:finished_at, parse_finished_at(params["finished_at"])) |> assign(:pending_form_params, nil) end end + @spec parse_finished_at(term()) :: DateTime.t() | nil + defp parse_finished_at(nil), do: nil + defp parse_finished_at(""), do: nil + + defp parse_finished_at(value) when is_binary(value) do + case DateTime.from_iso8601(value) do + {:ok, datetime, _offset} -> + datetime + + {:error, _} -> + case NaiveDateTime.from_iso8601(value) do + {:ok, naive} -> DateTime.from_naive!(naive, "Etc/UTC") + {:error, _} -> nil + end + end + end + + defp parse_finished_at(_), do: nil + @impl true def render(assigns) do ~H""" @@ -93,7 +116,7 @@ defmodule MusicLibraryWeb.Components.Release do :if={@record.selected_release_id} id={@sheet_id} placement="right" - class="min-w-xs sm:min-w-sm" + class="flex min-w-xs flex-col overflow-hidden p-0 sm:min-w-sm" > <.form for={@form} @@ -101,108 +124,142 @@ defmodule MusicLibraryWeb.Components.Release do phx-target={@myself} phx-change="validate" phx-auto-recover="recover_form" + class="flex min-h-0 flex-1 flex-col" > - - +
+ + -
- - <.button_group> - <.button - :if={@release_with_tracks.ok?} - type="button" - variant="soft" - size="sm" - phx-click="print_tracklist" - phx-target={@myself} - > - {gettext("Print tracklist")} - <.icon name="hero-printer" class="icon" aria-hidden="true" data-slot="icon" /> - - <.button - :if={@can_scrobble? && @release_with_tracks.ok?} - type="button" - variant="soft" - size="sm" - disabled={@already_scrobbled} - phx-click={ - if MapSet.size(@selected_tracks) > 0, - do: "scrobble_selected_tracks", - else: "scrobble_release" - } - phx-target={@myself} - phx-disable-with={gettext("Scrobbling...")} - > - {scrobble_button_label(@selected_tracks)} - <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> - - <.button :if={!@can_scrobble?} variant="soft" size="sm" href={LastFm.auth_url()}> - {gettext("Connect your Last.fm account")} - <.icon name="hero-link" class="icon" aria-hidden="true" data-slot="icon" /> - - +
+
+

+ {@record.title} +

+

+ {artist_names} +

+
+
+ <.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")} + + <:outer_suffix class="pr-2"> + <.button + size="sm" + type="button" + phx-click="reset_to_now" + phx-target={@myself} + > + {gettext("Now")} + + + + <.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" /> + + {gettext("Release")} + + <.dropdown id={"#{@sheet_id}-release-actions"} placement="bottom-end"> + <:toggle> + <.button type="button" variant="outline" size="sm"> + {gettext("More actions")} + <.icon + name="hero-ellipsis-vertical" + class="icon" + aria-hidden="true" + data-slot="icon" + /> + + + <.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 :if={!@can_scrobble?} href={LastFm.auth_url()}> + <.icon name="hero-link" class="icon" aria-hidden="true" data-slot="icon" /> + {gettext("Connect Last.fm")} + + + +
+
+ +
+ <.async_result :let={release_with_tracks} assign={@release_with_tracks}> + <:loading> +
+ {gettext("Loading release with tracks")} + <.loading /> +
+ + <:failed :let={_failure}> +
+ <.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")} + +
+ + <.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} + record={@record} + /> + +
-
- <.async_result :let={release_with_tracks} assign={@release_with_tracks}> - <:loading> -
- {gettext("Loading release with tracks")} - <.loading /> -
- - <:failed :let={_failure}> -
- <.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")} - -
- - <.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} - record={@record} - /> - -
+ <.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} + />
@@ -222,9 +279,9 @@ defmodule MusicLibraryWeb.Components.Release do ~H"""
1} - class="flex items-center justify-between gap-4" + class="flex items-center justify-between gap-3" > -
<.track_list medium_number={@medium.number} @@ -341,13 +414,64 @@ defmodule MusicLibraryWeb.Components.Release do """ end + attr :release, :any, required: true + attr :selected_tracks, :any, required: true + attr :already_scrobbled, :boolean, required: true + attr :myself, :any, required: true + + defp selection_bar(assigns) do + {count, medium_count, duration_ms} = + selected_tracks_summary(assigns.release, assigns.selected_tracks) + + assigns = + assign(assigns, + count: count, + medium_count: medium_count, + duration: Duration.format_duration(duration_ms) + ) + + ~H""" +
+
+

+ {ngettext("%{count} track selected", "%{count} tracks selected", @count, count: @count)} +

+

+ 1}> + {ngettext( + "across %{count} disc", + "across %{count} discs", + @medium_count, + count: @medium_count + )} · {@duration} + + {@duration} +

+
+ <.button + type="button" + variant="solid" + size="sm" + disabled={@already_scrobbled} + phx-click="scrobble_selected_tracks" + phx-target={@myself} + phx-disable-with={gettext("Scrobbling...")} + > + <.icon name="hero-play" class="icon" aria-hidden="true" data-slot="icon" /> + {gettext("Scrobble selected")} + +
+ """ + end + defguardp release_loaded?(assigns) when assigns.release_with_tracks.ok? @impl true def handle_event("scrobble_release", _params, socket) when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result + finished_at = socket.assigns.finished_at - case ScrobbleActivity.scrobble_release(release_with_tracks, :finished_at, DateTime.utc_now()) do + case ScrobbleActivity.scrobble_release(release_with_tracks, :finished_at, finished_at) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) put_toast!(:info, gettext("Release scrobbled successfully")) @@ -375,12 +499,13 @@ defmodule MusicLibraryWeb.Components.Release do when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result {number, ""} = Integer.parse(number) + finished_at = socket.assigns.finished_at case ScrobbleActivity.scrobble_medium( number, release_with_tracks, :finished_at, - DateTime.utc_now() + finished_at ) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) @@ -409,7 +534,13 @@ defmodule MusicLibraryWeb.Components.Release do when release_loaded?(socket.assigns) do release = socket.assigns.release_with_tracks.result new_selected = apply_form_params(release, params, socket.assigns.selected_tracks) - {:noreply, assign(socket, :selected_tracks, new_selected)} + finished_at_raw = params["finished_at"] + + {:noreply, + socket + |> assign(:selected_tracks, new_selected) + |> assign(:finished_at, parse_finished_at(finished_at_raw)) + |> assign(:form, to_form(%{"finished_at" => finished_at_raw}, as: :release))} end def handle_event("validate", %{"release" => params}, socket) do @@ -426,6 +557,15 @@ defmodule MusicLibraryWeb.Components.Release do handle_event("validate", params, socket) end + def handle_event("reset_to_now", _params, socket) do + current_time = DateTime.utc_now() + + {:noreply, + socket + |> assign(:finished_at, current_time) + |> assign(:form, to_form(%{"finished_at" => current_time}, as: :release))} + end + def handle_event("scrobble_selected_tracks", _params, socket) when release_loaded?(socket.assigns) do release_with_tracks = socket.assigns.release_with_tracks.result @@ -435,11 +575,13 @@ defmodule MusicLibraryWeb.Components.Release do put_toast!(:error, gettext("No tracks selected")) {:noreply, socket} else + finished_at = socket.assigns.finished_at + case ScrobbleActivity.scrobble_tracks( selected_track_ids, release_with_tracks, :finished_at, - DateTime.utc_now() + finished_at ) do {:ok, _} -> send_update_after(socket.assigns.myself, %{already_scrobbled: false}, 3000) @@ -628,4 +770,36 @@ defmodule MusicLibraryWeb.Components.Release do medium.title end end + + defp header_subtitle(%{artists: artists}) when is_list(artists) and artists != [] do + artists + |> Enum.map_join(", ", & &1.name) + |> case do + "" -> nil + names -> names + end + end + + defp header_subtitle(_record), do: nil + + @spec selected_tracks_summary(MusicBrainz.Release.t(), MapSet.t()) :: + {non_neg_integer(), non_neg_integer(), non_neg_integer()} + defp selected_tracks_summary(release, selected_tracks) do + {count, medium_count, duration_ms} = + Enum.reduce(release.media, {0, 0, 0}, fn medium, {count, medium_count, duration_ms} -> + matching = + Enum.filter(medium.tracks, &MapSet.member?(selected_tracks, &1.id)) + + case matching do + [] -> + {count, medium_count, duration_ms} + + tracks -> + tracks_duration = Enum.reduce(tracks, 0, &((&1.length || 0) + &2)) + {count + length(tracks), medium_count + 1, duration_ms + tracks_duration} + end + end) + + {count, medium_count, duration_ms} + end end diff --git a/lib/music_library_web/live/stats_live/index.ex b/lib/music_library_web/live/stats_live/index.ex index 51f3412f..a6d5152f 100644 --- a/lib/music_library_web/live/stats_live/index.ex +++ b/lib/music_library_web/live/stats_live/index.ex @@ -466,7 +466,7 @@ defmodule MusicLibraryWeb.StatsLive.Index do type="button" phx-click={JS.push("set_current_date", value: %{"current_date" => Date.utc_today()})} > - Today + {gettext("Today")} diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 69839428..b63d7796 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -551,7 +551,6 @@ msgstr "" msgid "Albums" msgstr "" -#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/show.ex #: lib/music_library_web/live/stats_live/index.ex #, elixir-autogen, elixir-format @@ -655,7 +654,6 @@ msgstr "" msgid "Release scrobbled successfully" msgstr "" -#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/index.ex #, elixir-autogen, elixir-format msgid "Connect your Last.fm account" @@ -1325,6 +1323,7 @@ msgid "Scrobble Anything" msgstr "" #: lib/music_library_web/components/stats_components.ex +#: lib/music_library_web/live/stats_live/index.ex #, elixir-autogen, elixir-format msgid "Today" msgstr "" @@ -1464,6 +1463,7 @@ msgstr[1] "" msgid "Never" msgstr "" +#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/collection_live/index.ex #: lib/music_library_web/live/wishlist_live/index.ex #, elixir-autogen, elixir-format @@ -2520,3 +2520,42 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Scan barcodes to add records." msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "%{count} track selected" +msgid_plural "%{count} tracks selected" +msgstr[0] "" +msgstr[1] "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Connect Last.fm" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Finished at" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "More actions" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Now" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Scrobble selected" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "across %{count} disc" +msgid_plural "across %{count} discs" +msgstr[0] "" +msgstr[1] "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 7f650513..65ec0fca 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -551,7 +551,6 @@ msgstr "" msgid "Albums" msgstr "" -#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/show.ex #: lib/music_library_web/live/stats_live/index.ex #, elixir-autogen, elixir-format @@ -655,7 +654,6 @@ msgstr "" msgid "Release scrobbled successfully" msgstr "" -#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/scrobble_live/index.ex #, elixir-autogen, elixir-format msgid "Connect your Last.fm account" @@ -1325,6 +1323,7 @@ msgid "Scrobble Anything" msgstr "" #: lib/music_library_web/components/stats_components.ex +#: lib/music_library_web/live/stats_live/index.ex #, elixir-autogen, elixir-format msgid "Today" msgstr "" @@ -1464,6 +1463,7 @@ msgstr[1] "" msgid "Never" msgstr "" +#: lib/music_library_web/components/release.ex #: lib/music_library_web/live/collection_live/index.ex #: lib/music_library_web/live/wishlist_live/index.ex #, elixir-autogen, elixir-format, fuzzy @@ -2520,3 +2520,42 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Scan barcodes to add records." msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "%{count} track selected" +msgid_plural "%{count} tracks selected" +msgstr[0] "" +msgstr[1] "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Connect Last.fm" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Finished at" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "More actions" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "Now" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format, fuzzy +msgid "Scrobble selected" +msgstr "" + +#: lib/music_library_web/components/release.ex +#, elixir-autogen, elixir-format +msgid "across %{count} disc" +msgid_plural "across %{count} discs" +msgstr[0] "" +msgstr[1] "" diff --git a/test/music_library_web/components/release_test.exs b/test/music_library_web/components/release_test.exs new file mode 100644 index 00000000..0c05b75f --- /dev/null +++ b/test/music_library_web/components/release_test.exs @@ -0,0 +1,289 @@ +defmodule MusicLibraryWeb.Components.ReleaseTest do + @moduledoc """ + Integration tests for the Release LiveComponent driven through `CollectionLive.Show`. + + Covers the UI changes introduced by ML-142: the `Finished at` picker, the sticky + selection bar, the re-enabled per-medium scrobble button, and the fact that all + three scrobble entry points now use the picker value (initialised to + `DateTime.utc_now/0` at mount, resettable via the `Now` button). + """ + use MusicLibraryWeb.ConnCase, async: false + + import MusicLibrary.Fixtures.Records + + import Phoenix.LiveViewTest, + only: [element: 2, render_async: 1, render_change: 2, render_click: 1] + + alias MusicBrainz.Fixtures.Release, as: ReleaseFixtures + alias MusicLibrary.Secrets + + @finished_at ~U[2026-03-15 21:00:00Z] + # `@sheet_form` scopes form-change events to the LiveComponent; button + # clicks are scoped by `[phx-target]` to avoid colliding with the parent + # LiveView's own scrobble_release button at `lib/music_library_web/live/collection_live/show.ex:58`. + @sheet_form "#release-with-tracks-sheet-form" + + defp stub_musicbrainz_release(_) do + Req.Test.stub(MusicBrainz.API, fn conn -> + case conn.request_path do + "/ws/2/release/" <> _id -> + Req.Test.json(conn, ReleaseFixtures.release_with_media(:marbles)) + + _ -> + Req.Test.json(conn, %{}) + end + end) + + :ok + end + + defp capture_lastfm_scrobble(_) do + test_pid = self() + + Req.Test.stub(LastFm.API, fn conn -> + {:ok, body, conn} = Plug.Conn.read_body(conn) + params = URI.decode_query(body) + send(test_pid, {:lastfm_scrobble, params}) + Req.Test.json(conn, %{"scrobbles" => %{"@attr" => %{"accepted" => 1}}}) + end) + + :ok + end + + defp store_lastfm_session_key(_) do + Secrets.store("last_fm_session_key", "test_session_key") + :ok + end + + defp release_with_tracks do + :marbles + |> ReleaseFixtures.release_with_media() + |> MusicBrainz.Release.from_api_response() + end + + defp first_track_id(release), + do: release.media |> List.first() |> Map.get(:tracks) |> List.first() |> Map.get(:id) + + defp second_medium_first_track_id(release), + do: release.media |> Enum.at(1) |> Map.get(:tracks) |> List.first() |> Map.get(:id) + + defp latest_timestamp(params) do + params + |> Enum.filter(fn {k, _v} -> String.starts_with?(k, "timestamp[") end) + |> Enum.map(fn {_k, v} -> String.to_integer(v) end) + |> Enum.max() + end + + describe "Finished at picker" do + setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble] + + test "renders 'Finished at' label when Last.fm is connected", %{conn: conn} do + record = record() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> assert_has("div[data-part=inner-prefix]", text: "Finished at") + end + + test "release scrobble uses mount-time utc_now when picker is not modified", %{conn: conn} do + record = record() + before_click = DateTime.utc_now() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element("button[phx-click=scrobble_release][phx-target]") + |> render_click() + end) + + after_click = DateTime.utc_now() + + assert_received {:lastfm_scrobble, params} + latest = latest_timestamp(params) + + # Scrobble timestamps reflect started_at + track offsets, so the latest + # timestamp is close to the click time (within a couple of seconds). + assert latest >= DateTime.to_unix(before_click) - 2 + assert latest <= DateTime.to_unix(after_click) + 2 + end + + test "release scrobble uses the picker value when set", %{conn: conn} do + record = record() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}}) + + view + |> element("button[phx-click=scrobble_release][phx-target]") + |> render_click() + end) + + assert_received {:lastfm_scrobble, params} + assert latest_timestamp(params) == DateTime.to_unix(@finished_at) + end + + test "reset_to_now event resets the picker back to the current time", %{conn: conn} do + record = record() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}}) + + view + |> element("button[phx-click=reset_to_now]") + |> render_click() + + view + |> element("button[phx-click=scrobble_release][phx-target]") + |> render_click() + end) + + now_unix = DateTime.to_unix(DateTime.utc_now()) + assert_received {:lastfm_scrobble, params} + latest = latest_timestamp(params) + + # After resetting, the scrobble should use `utc_now/0`, not the stale value. + assert abs(latest - now_unix) < 5 + refute latest == DateTime.to_unix(@finished_at) + end + end + + describe "Per-medium scrobble" do + setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble] + + test "medium-2 scrobble fires when a track on medium-1 is selected (AC#3 regression)", + %{conn: conn} do + record = record() + release = release_with_tracks() + medium_1_track = first_track_id(release) + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"selected_tracks" => [medium_1_track]}}) + + view + |> element("button[phx-click=scrobble_medium][phx-value-number='2']") + |> render_click() + end) + |> assert_has("#toast-group", text: "Disc scrobbled successfully") + + assert_received {:lastfm_scrobble, params} + # Medium 2's tracks were scrobbled, not the medium-1 selection. + medium_2_first_title = + release.media |> Enum.at(1) |> Map.get(:tracks) |> List.first() |> Map.get(:title) + + assert params["track[0]"] == medium_2_first_title + end + + test "medium scrobble uses picker value when set", %{conn: conn} do + record = record() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"finished_at" => DateTime.to_iso8601(@finished_at)}}) + + view + |> element("button[phx-click=scrobble_medium][phx-value-number='2']") + |> render_click() + end) + + assert_received {:lastfm_scrobble, params} + assert latest_timestamp(params) == DateTime.to_unix(@finished_at) + end + end + + describe "Sticky selection bar" do + setup [:stub_musicbrainz_release, :store_lastfm_session_key, :capture_lastfm_scrobble] + + test "is hidden when no tracks are selected", %{conn: conn} do + record = record() + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> refute_has("button", text: "Scrobble selected") + end + + test "appears with a singular count when one track is selected", %{conn: conn} do + record = record() + release = release_with_tracks() + track_id = first_track_id(release) + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"selected_tracks" => [track_id]}}) + end) + |> assert_has("p", text: "1 track selected") + |> assert_has("button", text: "Scrobble selected") + end + + test "shows cross-medium copy when selection spans discs", %{conn: conn} do + record = record() + release = release_with_tracks() + t1 = first_track_id(release) + t2 = second_medium_first_track_id(release) + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{"release" => %{"selected_tracks" => [t1, t2]}}) + end) + |> assert_has("p", text: "2 tracks selected") + |> assert_has("span", text: "across 2 discs") + end + + test "Scrobble selected uses picker value", %{conn: conn} do + record = record() + release = release_with_tracks() + track_id = first_track_id(release) + + conn + |> visit(~p"/collection/#{record.id}") + |> unwrap(&render_async/1) + |> unwrap(fn view -> + view + |> element(@sheet_form) + |> render_change(%{ + "release" => %{ + "selected_tracks" => [track_id], + "finished_at" => DateTime.to_iso8601(@finished_at) + } + }) + + view + |> element("button[phx-click=scrobble_selected_tracks]") + |> render_click() + end) + + assert_received {:lastfm_scrobble, params} + assert latest_timestamp(params) == DateTime.to_unix(@finished_at) + end + end +end diff --git a/test/music_library_web/live/collection_live/show_test.exs b/test/music_library_web/live/collection_live/show_test.exs index 93b43d0a..8f30f951 100644 --- a/test/music_library_web/live/collection_live/show_test.exs +++ b/test/music_library_web/live/collection_live/show_test.exs @@ -83,7 +83,7 @@ defmodule MusicLibraryWeb.CollectionLive.ShowTest do |> visit(~p"/collection/#{record.id}") |> assert_has("button", "Show Tracks") |> unwrap(&render_async/1) - |> assert_has("a", "Connect your Last.fm account") + |> assert_has("a", "Connect Last.fm") release = MusicBrainz.Release.from_api_response(release_response) diff --git a/test/music_library_web/live/scrobble_live/show_test.exs b/test/music_library_web/live/scrobble_live/show_test.exs index 425e6f16..48d44538 100644 --- a/test/music_library_web/live/scrobble_live/show_test.exs +++ b/test/music_library_web/live/scrobble_live/show_test.exs @@ -112,6 +112,23 @@ defmodule MusicLibraryWeb.ScrobbleLive.ShowTest do |> assert_has("#toast-group", "Disc scrobbled successfully") end + test "scrobble single medium still works with tracks selected elsewhere", %{conn: conn} do + track_id = first_track_id() + session = visit(conn, ~p"/scrobble/#{@release_id}") + + session + |> unwrap(fn view -> + # Select a track first — AC#3 regression: medium scrobble must not be + # blocked just because something is ticked. + view + |> element("#scrobble-release-form") + |> render_change(%{"release" => %{"selected_tracks" => [track_id]}}) + + render_click(view, "scrobble_medium", %{"number" => "1"}) + end) + |> assert_has("#toast-group", "Disc scrobbled successfully") + end + test "toggle track on and off changes button label", %{conn: conn} do track_id = first_track_id()