ML-142: Accepted mockup and plan

This commit is contained in:
Claudio Ortolina
2026-04-22 13:37:35 +01:00
parent 1ca17d2fb3
commit 70b51b3790
3 changed files with 741 additions and 5 deletions
+534
View File
@@ -0,0 +1,534 @@
<style>
:root {
--z-50: #fafafa;
--z-100: #f4f4f5;
--z-200: #e4e4e7;
--z-300: #d4d4d8;
--z-400: #a1a1aa;
--z-500: #71717a;
--z-600: #52525b;
--z-700: #3f3f46;
--z-800: #27272a;
--z-900: #18181b;
--z-950: #09090b;
--blue-500: #3b82f6;
--blue-600: #2563eb;
--blue-700: #1d4ed8;
--blue-soft: #dbeafe;
--surface: #ffffff;
--surface-muted: var(--z-50);
--surface-elevated: var(--z-100);
--border-c: var(--z-200);
--text: var(--z-900);
--text-muted: var(--z-500);
--text-subtle: var(--z-400);
--primary: var(--blue-600);
--primary-soft-bg: var(--blue-soft);
--primary-soft-text: var(--blue-700);
--accent-row: #eff6ff;
}
@media (prefers-color-scheme: dark) {
:root {
--surface: var(--z-900);
--surface-muted: var(--z-800);
--surface-elevated: #1f1f22;
--border-c: var(--z-700);
--text: var(--z-100);
--text-muted: var(--z-400);
--text-subtle: var(--z-500);
--primary: #60a5fa;
--primary-soft-bg: rgba(59, 130, 246, 0.15);
--primary-soft-text: #93c5fd;
--accent-row: rgba(59, 130, 246, 0.08);
}
}
.ml-sheet {
background: var(--surface);
border: 1px solid var(--border-c);
border-radius: 14px;
overflow: hidden;
font-size: 0.82rem;
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 8px 24px rgba(0,0,0,0.06);
}
.ml-sheet-header {
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border-c);
background: var(--surface);
}
.ml-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.ml-title { font-weight: 600; font-size: 0.98rem; color: var(--text); line-height: 1.2; }
.ml-subtitle { color: var(--text-muted); font-size: 0.75rem; margin-top: 0.15rem; }
.ml-actions { display: flex; align-items: center; gap: 0.4rem; flex-wrap: nowrap; }
.ml-picker {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.6rem 0.35rem 0.5rem;
background: var(--surface-muted);
border: 1px solid var(--border-c);
border-radius: 8px;
font-size: 0.76rem;
color: var(--text);
cursor: default;
white-space: nowrap;
}
.ml-picker svg { color: var(--text-muted); flex-shrink: 0; }
.ml-picker-label { color: var(--text-muted); font-size: 0.7rem; }
.ml-picker-value { font-weight: 500; font-variant-numeric: tabular-nums; }
.ml-picker-caret { color: var(--text-subtle); margin-left: 0.1rem; }
.ml-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
padding: 0.4rem 0.7rem;
font-size: 0.78rem;
font-weight: 500;
border-radius: 8px;
border: 1px solid transparent;
cursor: default;
white-space: nowrap;
font-family: inherit;
}
.ml-btn svg { width: 14px; height: 14px; }
.ml-btn-solid { background: var(--primary); color: white; border-color: var(--primary); }
.ml-btn-soft { background: var(--primary-soft-bg); color: var(--primary-soft-text); border-color: transparent; }
.ml-btn-outline{ background: var(--surface); color: var(--text); border-color: var(--border-c); }
.ml-btn-ghost { background: transparent; color: var(--text-muted); }
.ml-btn.icon-only { padding: 0; width: 30px; height: 30px; }
.ml-btn.disabled { opacity: 0.45; }
.ml-btn-group { display: inline-flex; gap: 0.35rem; }
/* Medium header */
.ml-medium-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.65rem 1rem;
background: var(--surface-muted);
border-bottom: 1px solid var(--border-c);
}
.ml-medium-title { display: flex; align-items: center; gap: 0.55rem; font-weight: 600; font-size: 0.82rem; }
.ml-badge {
display: inline-flex;
align-items: center;
gap: 0.2rem;
padding: 0.1rem 0.45rem;
background: var(--surface);
border: 1px solid var(--border-c);
border-radius: 999px;
font-size: 0.65rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
/* Checkbox */
.ml-chk {
appearance: none;
width: 15px; height: 15px;
border: 1.5px solid var(--z-400);
border-radius: 4px;
position: relative;
display: inline-block;
cursor: pointer;
background: var(--surface);
flex-shrink: 0;
}
.ml-chk.checked { background: var(--primary); border-color: var(--primary); }
.ml-chk.checked::after {
content: '';
position: absolute;
left: 3px; top: 0px;
width: 5px; height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
/* Track row */
.ml-track {
padding: 0.55rem 1rem 0.55rem 1rem;
border-bottom: 1px solid var(--border-c);
display: grid;
grid-template-columns: 15px 22px 1fr auto;
gap: 0.6rem;
align-items: center;
font-size: 0.8rem;
}
.ml-track:last-child { border-bottom: none; }
.ml-track.selected { background: var(--accent-row); }
.ml-track-num { color: var(--text-subtle); font-size: 0.72rem; text-align: right; font-variant-numeric: tabular-nums; }
.ml-track-title { color: var(--text); }
.ml-track-dur { color: var(--text-muted); font-size: 0.72rem; font-variant-numeric: tabular-nums; }
/* Sticky selection bar */
.ml-sticky {
position: sticky;
bottom: 0;
background: var(--surface);
border-top: 1px solid var(--border-c);
box-shadow: 0 -6px 18px rgba(0,0,0,0.08);
padding: 0.65rem 1rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.ml-sticky-info { display: flex; flex-direction: column; line-height: 1.2; }
.ml-sticky-count { font-size: 0.82rem; font-weight: 600; color: var(--text); }
.ml-sticky-meta { font-size: 0.7rem; color: var(--text-muted); }
.ml-body { max-height: 520px; overflow-y: auto; scrollbar-width: thin; }
/* Viewport frames */
.viewport-frame {
border: 1px solid var(--border-c);
border-radius: 14px;
background: var(--surface-muted);
padding: 0.8rem;
}
.viewport-label {
font-size: 0.7rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
display: flex; justify-content: space-between;
}
.viewport-label .dim { color: var(--text-subtle); font-variant-numeric: tabular-nums; }
.grid-two {
display: grid;
grid-template-columns: 1fr 380px;
gap: 1.5rem;
align-items: start;
}
@media (max-width: 1000px) {
.grid-two { grid-template-columns: 1fr; }
}
.annotation {
background: var(--primary-soft-bg);
color: var(--primary-soft-text);
border-left: 3px solid var(--primary);
padding: 0.6rem 0.8rem;
font-size: 0.78rem;
margin-top: 0.75rem;
border-radius: 4px;
}
.annotation strong { font-weight: 600; }
.hierarchy-diagram {
background: var(--surface);
border: 1px solid var(--border-c);
border-radius: 10px;
padding: 1rem;
margin-top: 1rem;
font-size: 0.82rem;
}
.h-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.4rem 0; }
.h-label { font-weight: 600; width: 130px; color: var(--text); }
.h-arrow { color: var(--text-subtle); }
.h-scope { color: var(--text-muted); }
/* Mobile-specific overrides on narrow viewport mock */
.m-narrow .ml-title-row { flex-direction: column; align-items: stretch; }
.m-narrow .ml-title-row .ml-actions-top { justify-content: space-between; }
.m-narrow .ml-picker { flex: 1; justify-content: flex-start; }
.m-narrow .ml-btn-row {
display: flex;
gap: 0.4rem;
margin-top: 0.5rem;
}
.m-narrow .ml-btn-row .ml-picker { flex: 1; }
.m-narrow .ml-btn-row .ml-btn-solid { flex: 0 0 auto; }
</style>
<h2>Option A — v2, app-styled + mobile layout + explicit "Scrobble release"</h2>
<p class="subtitle">Addresses the three iteration asks: using the app's zinc/blue palette and Fluxon button variants, dedicated mobile layout, and a clear top-level "Scrobble release" action.</p>
<div class="hierarchy-diagram">
<div style="font-size: 0.72rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.4rem;">Action hierarchy</div>
<div class="h-row">
<span class="h-label">Header action</span>
<span class="h-arrow"></span>
<span class="ml-btn ml-btn-solid" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble release</span>
<span class="h-scope">all tracks, one submission</span>
</div>
<div class="h-row">
<span class="h-label">Per-medium action</span>
<span class="h-arrow"></span>
<span class="ml-btn ml-btn-soft" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble disc</span>
<span class="h-scope">tracks of that medium</span>
</div>
<div class="h-row">
<span class="h-label">Selection action</span>
<span class="h-arrow"></span>
<span class="ml-btn ml-btn-solid" style="padding: 0.2rem 0.5rem;"><svg viewBox="0 0 20 20" fill="currentColor" width="12" height="12"><path d="M6 4l10 6-10 6V4z"/></svg> Scrobble selected</span>
<span class="h-scope">only ticked rows — appears only when something is ticked</span>
</div>
</div>
<div class="grid-two" style="margin-top: 1.5rem;">
<!-- DESKTOP -->
<div>
<div class="viewport-label">
<span>Desktop · sheet width ~520px</span>
<span class="dim">Fluxon variants: solid primary / soft primary / outline</span>
</div>
<div class="viewport-frame">
<div class="ml-sheet">
<div class="ml-sheet-header">
<div class="ml-title-row">
<div>
<div class="ml-title">Kid A Mnesia</div>
<div class="ml-subtitle">Radiohead · 2 CDs · 2021</div>
</div>
<div class="ml-actions">
<span class="ml-picker" title="Click to change">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .2.08.39.22.53l3 3a.75.75 0 101.06-1.06l-2.78-2.78V5z" clip-rule="evenodd"/></svg>
<span class="ml-picker-label">Finished at</span>
<span class="ml-picker-value">Now · Apr 22, 14:30</span>
<span class="ml-picker-caret"></span>
</span>
<button class="ml-btn ml-btn-solid">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Scrobble release
</button>
<button class="ml-btn ml-btn-outline icon-only" title="More">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
</div>
</div>
<div class="ml-body">
<div class="ml-medium-header">
<div class="ml-medium-title">
<span class="ml-chk"></span>
Disc 1
<span class="ml-badge">CD</span>
</div>
<div class="ml-btn-group">
<button class="ml-btn ml-btn-soft">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Scrobble disc
</button>
<button class="ml-btn ml-btn-outline icon-only">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
</div>
<div class="ml-track selected">
<span class="ml-chk checked"></span>
<span class="ml-track-num">1</span>
<span class="ml-track-title">How to Disappear Completely</span>
<span class="ml-track-dur">5:56</span>
</div>
<div class="ml-track">
<span class="ml-chk"></span>
<span class="ml-track-num">2</span>
<span class="ml-track-title">The National Anthem</span>
<span class="ml-track-dur">5:51</span>
</div>
<div class="ml-track">
<span class="ml-chk"></span>
<span class="ml-track-num">3</span>
<span class="ml-track-title">Everything in Its Right Place</span>
<span class="ml-track-dur">4:11</span>
</div>
<div class="ml-medium-header">
<div class="ml-medium-title">
<span class="ml-chk"></span>
Disc 2
<span class="ml-badge">CD</span>
</div>
<div class="ml-btn-group">
<button class="ml-btn ml-btn-soft">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Scrobble disc
</button>
<button class="ml-btn ml-btn-outline icon-only">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
</div>
<div class="ml-track selected">
<span class="ml-chk checked"></span>
<span class="ml-track-num">1</span>
<span class="ml-track-title">Pyramid Song</span>
<span class="ml-track-dur">4:49</span>
</div>
<div class="ml-track">
<span class="ml-chk"></span>
<span class="ml-track-num">2</span>
<span class="ml-track-title">Idioteque</span>
<span class="ml-track-dur">5:09</span>
</div>
<div class="ml-track">
<span class="ml-chk"></span>
<span class="ml-track-num">3</span>
<span class="ml-track-title">Like Spinning Plates</span>
<span class="ml-track-dur">3:57</span>
</div>
<div class="ml-sticky">
<div class="ml-sticky-info">
<span class="ml-sticky-count">2 tracks selected</span>
<span class="ml-sticky-meta">across 2 discs · 10:45</span>
</div>
<button class="ml-btn ml-btn-solid">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Scrobble selected
</button>
</div>
</div>
</div>
</div>
<div class="annotation">
<strong>Scrobble release</strong> sits in the header beside the "Finished at" picker. Solid primary to mark the default action. Per-medium <strong>Scrobble disc</strong> uses the soft variant to stay a visible shortcut without competing with the primary.
</div>
</div>
<!-- MOBILE -->
<div>
<div class="viewport-label">
<span>Mobile · 360px width</span>
<span class="dim">iPhone SE class</span>
</div>
<div class="viewport-frame" style="padding: 0.5rem;">
<div class="ml-sheet m-narrow" style="font-size: 0.8rem;">
<div class="ml-sheet-header" style="padding: 0.8rem 0.75rem;">
<div class="ml-title-row">
<div class="ml-actions-top" style="display: flex; justify-content: space-between; align-items: start; gap: 0.5rem;">
<div>
<div class="ml-title" style="font-size: 0.92rem;">Kid A Mnesia</div>
<div class="ml-subtitle">Radiohead · 2 CDs</div>
</div>
<button class="ml-btn ml-btn-outline icon-only" title="More">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
<div class="ml-btn-row">
<span class="ml-picker" style="flex: 1; justify-content: space-between;">
<span style="display: inline-flex; align-items: center; gap: 0.4rem;">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm.75-13a.75.75 0 00-1.5 0v5c0 .2.08.39.22.53l3 3a.75.75 0 101.06-1.06l-2.78-2.78V5z" clip-rule="evenodd"/></svg>
<span>
<span class="ml-picker-label">Finished at</span>
<span class="ml-picker-value" style="margin-left: 0.25rem;">Now</span>
</span>
</span>
<span class="ml-picker-caret"></span>
</span>
<button class="ml-btn ml-btn-solid" style="padding: 0.4rem 0.65rem;">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Release
</button>
</div>
</div>
</div>
<div class="ml-body" style="max-height: 360px;">
<div class="ml-medium-header" style="padding: 0.55rem 0.75rem;">
<div class="ml-medium-title" style="font-size: 0.78rem;">
<span class="ml-chk"></span>
Disc 1
<span class="ml-badge">CD</span>
</div>
<div class="ml-btn-group">
<button class="ml-btn ml-btn-soft icon-only" title="Scrobble disc">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M6 4l10 6-10 6V4z"/></svg>
</button>
<button class="ml-btn ml-btn-outline icon-only">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
</div>
<div class="ml-track selected" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
<span class="ml-chk checked"></span>
<span class="ml-track-num">1</span>
<span class="ml-track-title">How to Disappear Completely</span>
<span class="ml-track-dur">5:56</span>
</div>
<div class="ml-track" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
<span class="ml-chk"></span>
<span class="ml-track-num">2</span>
<span class="ml-track-title">The National Anthem</span>
<span class="ml-track-dur">5:51</span>
</div>
<div class="ml-medium-header" style="padding: 0.55rem 0.75rem;">
<div class="ml-medium-title" style="font-size: 0.78rem;">
<span class="ml-chk"></span>
Disc 2
<span class="ml-badge">CD</span>
</div>
<div class="ml-btn-group">
<button class="ml-btn ml-btn-soft icon-only" title="Scrobble disc">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><path d="M6 4l10 6-10 6V4z"/></svg>
</button>
<button class="ml-btn ml-btn-outline icon-only">
<svg viewBox="0 0 20 20" fill="currentColor" width="14" height="14"><circle cx="10" cy="4" r="1.5"/><circle cx="10" cy="10" r="1.5"/><circle cx="10" cy="16" r="1.5"/></svg>
</button>
</div>
</div>
<div class="ml-track selected" style="padding: 0.5rem 0.75rem; grid-template-columns: 15px 20px 1fr auto;">
<span class="ml-chk checked"></span>
<span class="ml-track-num">1</span>
<span class="ml-track-title">Pyramid Song</span>
<span class="ml-track-dur">4:49</span>
</div>
<div class="ml-sticky" style="padding: 0.55rem 0.75rem;">
<div class="ml-sticky-info">
<span class="ml-sticky-count" style="font-size: 0.78rem;">2 selected</span>
<span class="ml-sticky-meta">10:45</span>
</div>
<button class="ml-btn ml-btn-solid" style="padding: 0.4rem 0.65rem;">
<svg viewBox="0 0 20 20" fill="currentColor"><path d="M6 4l10 6-10 6V4z"/></svg>
Scrobble
</button>
</div>
</div>
</div>
</div>
<div class="annotation">
<strong>Mobile reflow:</strong> title row stacks title + ⋯; the picker and "Release" button share a full-width row (picker grows, button hugs). Per-medium scrobble becomes icon-only to fit. Sticky bar label shortens to "Scrobble" + count + duration.
</div>
</div>
</div>
<div style="margin-top: 1.5rem; padding: 0.9rem 1rem; background: var(--surface); border-radius: 10px; border: 1px solid var(--border-c); font-size: 0.8rem; color: var(--text);">
<strong>Design language notes</strong><br>
<ul style="margin-top: 0.4rem; padding-left: 1.2rem; line-height: 1.6;">
<li>Surfaces use the zinc palette (zinc-50 / zinc-200 / zinc-800) seen across the app.</li>
<li>Buttons use Fluxon variants: solid primary (release + selection), soft primary (per-medium shortcut), outline (menu &amp; picker).</li>
<li>Icons are hero-style (outline-filled), matching existing `hero-play`, `hero-ellipsis-vertical`, `hero-clock`.</li>
<li>Selected rows use a subtle blue-tinted background — matches the dimmed-accent pattern already in the Collection list.</li>
<li>Dark mode swaps to zinc-900/800/700 and a lighter blue-400 primary; the mockup honours `prefers-color-scheme`.</li>
</ul>
</div>
+183
View File
@@ -0,0 +1,183 @@
# Improve scrobble UI in the Release component
## Context
The Release sheet's scrobble controls have three concrete friction points, confirmed on review:
1. **Navigation.** On a multi-medium release, the only way to scrobble a cross-medium selection is the top-level button — users have to scroll all the way back up after picking tracks on disc 3.
2. **No picker for `finished_at`.** The timestamp is always `DateTime.utc_now()`; there is no UI to backfill a listening session. `ScrobbleActivity.scrobble_release/3`, `scrobble_medium/4`, and `scrobble_tracks/4` already accept a `DateTime`, so this is purely a UI gap.
3. **Disabled buttons aren't visually distinct.** All scrobble buttons use `variant="soft"` with only `opacity-70` for disabled — hard to tell state apart. The per-medium button is *always* disabled when any track is selected anywhere, which is surprising.
A fourth issue surfaced in review: the **print** icon is omnipresent (release + every medium) but rarely used, taking up valuable primary-action space.
The accepted UX direction (Option A, see `backlog/ml-142/mockups.html`) is a three-tier action hierarchy:
- **Header → whole release** (primary action, solid button)
- **Per-medium → that disc** (soft shortcut, always enabled)
- **Sticky footer → selected tracks** (appears only when tracks are ticked)
A single "Finished at" picker in the header drives all three, defaulting to "Now" (nil) until changed. Print moves into a `⋯` overflow menu on both the release header and each medium header.
## Scope
### In scope
- `lib/music_library_web/components/release.ex` — the LiveComponent and the shared `.medium/1` / `.track_list/1` function components it exports.
- `lib/music_library_web/live/scrobble_live/show.ex` — the standalone scrobble page, which imports `.medium/1`; behaviour must stay coherent with the new component contract (the selection-blocks-medium-scrobble bug is removed there too).
- New user-facing strings wrapped in `gettext`; `.pot`/`.po` regenerated.
- Tests: new coverage for the Release LiveComponent's event handlers and the sticky-bar rendering, plus an updated handler test for `ScrobbleLive.Show` confirming medium-scrobble now works with tracks selected.
### Out of scope
- `MusicLibrary.ScrobbleActivity` — no context changes. All three functions already take a `DateTime`.
- A full sticky-bar + picker port to `ScrobbleLive.Show`. That page uses its own layout (not the sheet) and can be iterated on separately once the LiveComponent pattern is settled.
- Changes to `ScrobbleComponents` function module — it isn't used by the Release sheet.
- No database, migration, Oban, or JS-hook work.
## Implementation
### 1. Header restructure (`release.ex:89-164`)
Replace the current flex row ("Tracks" label + print + scrobble + auth) with a two-block structure that reflows on narrow viewports:
```
<div class="flex items-start justify-between gap-3">
<div>
{release_title}
{release_subtitle} (# of mediums · year)
</div>
<div class="flex flex-wrap items-center justify-end gap-2">
<.date_time_picker_pill ... />
<.button variant="solid" phx-click="scrobble_release" ...>
▶ Scrobble release
</.button>
<.dropdown>
<.button variant="outline" size="sm">⋯</.button>
<:item><.dropdown_link phx-click="print_tracklist">Print tracklist</.dropdown_link></:item>
<:item :if={!@can_scrobble?}><.dropdown_link href={LastFm.auth_url()}>Connect Last.fm</.dropdown_link></:item>
</.dropdown>
</div>
</div>
```
- Drop the existing single-medium "Tracks" master checkbox (it duplicates the medium-level checkbox that's always rendered now).
- Mobile (`< sm:`): the wrapping grid collapses so the title row holds title + `⋯`, and the second row holds the picker (flex-1) + "Scrobble release" (hug contents).
- The "Scrobble release" button is `variant="solid"` (Fluxon's primary colour) — this is the contrast fix mandated by AC#2 on the primary flow.
### 2. `.medium/1` function component (`release.ex:221-283`)
Three changes:
- **Remove the selection-based disable** on the scrobble button (`release.ex:260`): `disabled={@already_scrobbled || MapSet.size(@selected_tracks) > 0}``disabled={@already_scrobbled}`. This is the AC#3 unblock.
- **Add a label** to the scrobble button ("Scrobble disc" / "Scrobble side" via `medium_scrobble_label/1`, which already exists). Keep `variant="soft"`, `color="primary"` — visually secondary to the release button but with colour anchoring it as "this is scrobbling".
- **Replace the separate print button** with a `.dropdown` trigger identical in shape to the header's `⋯` menu; print lives inside. Keeps one row per medium without losing the feature.
The function-component signature stays backward-compatible: the existing `myself` / `already_scrobbled` / `selected_tracks` attrs are retained. `ScrobbleLive.Show` renders this component unchanged, so the fix propagates there automatically.
### 3. Sticky selection bar (new, inside `release.ex` render)
Rendered at the bottom of the sheet body, conditional on `MapSet.size(@selected_tracks) > 0`:
```
<div class="sticky bottom-0 bg-white dark:bg-zinc-900 border-t border-zinc-200 dark:border-zinc-700
shadow-[0_-4px_14px_rgba(0,0,0,0.06)] px-4 py-2 flex items-center justify-between gap-3">
<div class="flex flex-col leading-tight">
<span class="text-sm font-semibold">{count} tracks selected</span>
<span class="text-xs text-zinc-500 dark:text-zinc-400">across {medium_count} discs · {duration}</span>
</div>
<.button variant="solid" phx-click="scrobble_selected_tracks" ...>
▶ Scrobble selected
</.button>
</div>
```
Helpers (private, in `release.ex`):
- `selected_tracks_summary/2` — takes `%{release, tracks: [...]}`and `MapSet` → returns `{count, medium_count, total_duration_ms}`. Reuses the existing `Release.tracks/1` flattening.
- Duration formatted via the existing `MusicLibraryWeb.Duration.format_milliseconds/1`.
The sticky element sits inside the scrollable sheet body so it pins to the bottom of the visible area, not the viewport.
### 4. `finished_at` wiring
Add a `finished_at` field to the form data in `release.ex`:
- `mount/1`: initial form is `to_form(%{"selected_tracks" => [], "toggle_medium" => [], "finished_at" => nil})`.
- `handle_event("validate", params, socket)` (already exists, lines 408-423): extend `apply_form_params/3` to parse `params["release"]["finished_at"]` into `DateTime.t() | nil` (ISO8601 via `DateTime.from_iso8601/1`, nil on blank or parse failure) and assign under `:finished_at`.
- The three scrobble handlers (`scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks`) resolve the timestamp at call time:
```elixir
finished_at = socket.assigns.finished_at || DateTime.utc_now()
```
Then pass it as the existing `:finished_at` argument to `ScrobbleActivity.*`.
Rendering the control: use Fluxon's `<.date_time_picker field={@form[:finished_at]} label={gettext("Finished at")} display_format="%b %-d, %H:%M" />`, already used in `record_form.ex:57-63`. When the field is nil, the picker shows its placeholder; rendering "Now" as the displayed value when `@finished_at` is nil is done via a small wrapper (`finished_at_display/1`) rather than the picker internals, so the behaviour is obvious in the template.
A **Reset to now** affordance: if Fluxon's picker exposes a clear action (likely via its popover footer — confirm in browser), use it; otherwise mimic pattern used in `MusicLibraryWeb.StatsLive.Index` for the `on_this_day/1` component, which adds a button next to the picker that pushes a `"clear_finished_at"` event which `assign(:finished_at, nil)` and updates the form.
### 5. Event handlers — summary of changes
| Event | Before | After |
|---|---|---|
| `scrobble_release` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` |
| `scrobble_medium` | disabled when selection present; always `utc_now()` | always enabled; uses `@finished_at || utc_now()` |
| `scrobble_selected_tracks` | `DateTime.utc_now()` hardcoded | `@finished_at || DateTime.utc_now()` |
| `clear_finished_at` | — | new; resets form field + assign |
| `validate` | parses `selected_tracks` + `toggle_medium` | also parses `finished_at` |
The `already_scrobbled` 3-second lockout stays as-is.
### 6. `ScrobbleLive.Show` — minimal updates
`lib/music_library_web/live/scrobble_live/show.ex`:
- The imported `.medium/1` renders the new "Scrobble disc" label + `` dropdown; since this page has its own print handlers already (`"print_medium_tracklist"`), wire the dropdown `phx-click` to those.
- `handle_event("scrobble_medium", ...)` loses no behaviour — it already doesn't gate on selection (the guard was only in the component). Verify and add a regression test.
- No picker, no sticky bar added in this pass — those stay scoped to the LiveComponent. The page continues to use `DateTime.utc_now()`.
### 7. Gettext
New strings (all under existing `MusicLibraryWeb.Gettext`):
- `"Finished at"`, `"Now"`, `"Reset to now"`
- `"Scrobble release"`, `"Scrobble disc"`, `"Scrobble selected"`
- ngettext: `"%{count} track selected"` / `"%{count} tracks selected"`
- `"across %{count} discs"` — may already exist; reuse if so
- `"More actions"` — for the `` button's screen-reader label
Run `mix gettext.extract --merge` after implementation.
## Files touched
- `lib/music_library_web/components/release.ex` — the primary change (render, handlers, helpers).
- `lib/music_library_web/live/scrobble_live/show.ex` — dropdown wiring for print; regression test for medium-scrobble-with-selection.
- `test/music_library_web/components/release_test.exs` — **new file**. Covers:
- Picker: default "Now" render; setting a value persists; `clear_finished_at` resets to nil.
- Sticky bar: hidden when no selection; visible with accurate count/duration when tracks ticked.
- `scrobble_release`, `scrobble_medium`, `scrobble_selected_tracks` each respect the picker value (assert the arg passed to `ScrobbleActivity` via a `Mox`-style expect or by stubbing `LastFm` via `Req.Test` and asserting on the scrobbled timestamps).
- Medium button is clickable (not disabled) when cross-medium selection is present.
- `test/music_library_web/live/scrobble_live/show_test.exs` — update / add regression asserting `scrobble_medium` works with selection.
- `priv/gettext/*.pot`, `priv/gettext/*/LC_MESSAGES/*.po` — regenerated by `mix gettext.extract --merge`.
No other files expected. Confirmed: `scrobble_components.ex` is not used by the Release sheet.
## Verification
1. `mise run dev:precommit` — format, Credo, Sobelow, ExSlop, gettext check, tests. Must pass.
2. Manual browser checks with the dev server running. Required for UI work per project convention:
- **Desktop (~1200px, light + dark mode):** open a 2-CD release in Collection. Header shows picker + "Scrobble release" + ``. Per-medium rows show "Scrobble disc" + ``. Print lives only inside `` menus. Contrast: solid release button is obviously different from disabled state (3-second lockout after scrobble).
- **Mobile (360px):** header stacks — title+⋯ row, then picker+"Release" button row. Per-medium button collapses to icon-only. Sticky bar still readable (no horizontal overflow).
- **Cross-medium scrobble:** tick one track on disc 1, one on disc 2 → sticky bar appears "2 tracks selected across 2 discs · 10:45" → click "Scrobble selected" → toast, sticky bar disappears.
- **Per-medium scrobble with selection present:** tick a track on disc 1 → click "Scrobble disc" on disc 2 → disc 2 scrobbles (unaffected by selection). Verify via `/dev/oban` or Last.fm.
- **Custom finished_at:** pick a time yesterday → click "Scrobble release" → verify the scrobbled timestamp in Last.fm matches the picked time (minus release duration for started_at).
- **Reset to now:** set a custom time → click reset → picker shows "Now" → scrobble uses `utc_now()`.
3. `mix test test/music_library_web/components/release_test.exs` — new LiveComponent tests pass.
4. `mix test test/music_library_web/live/scrobble_live/show_test.exs` — regression test for medium-scrobble-with-selection passes.
5. `grep -n "MapSet.size(@selected_tracks) > 0" lib/music_library_web/components/release.ex` — should return nothing except the sticky-bar visibility guard (which is intended).
## Open items deferred to implementation
- Exact Tailwind classes for the sticky bar shadow/padding — start with the mockup values, tune in the browser.
- Pluralisation of "across N discs" — if the release is single-medium, the subtitle should drop the "across 1 discs" clause and show just the duration. Small conditional in `selected_tracks_summary/2`.
- Whether to show the picker on releases where `@can_scrobble?` is false. Recommend hiding it in that case (no point picking a time if you can't submit) — to confirm in review.
@@ -1,10 +1,10 @@
---
id: ML-142
title: Improve scrobble UI in the Release component
status: To Do
status: In Progress
assignee: []
created_date: '2026-04-20 09:32'
updated_date: '2026-04-20 09:35'
updated_date: '2026-04-22 12:42'
labels:
- ui
- scrobble
@@ -12,7 +12,13 @@ dependencies: []
references:
- lib/music_library_web/live/components/release.ex
- lib/music_library_web/components/scrobble_components.ex
- backlog/ml-142/mockups.html
- backlog/ml-142/plan.md
documentation:
- backlog/ml-142/plan.md
- backlog/ml-142/mockups.html
priority: medium
ordinal: 1000
---
## Description
@@ -23,7 +29,20 @@ The Release component's scrobble interface has several usability gaps that make
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Users can set a custom `finished_at` datetime before scrobbling, defaulting to the current time when not set
- [ ] #2 Scrobble buttons have sufficient contrast in both their enabled and disabled states so users can clearly distinguish between the two
- [ ] #3 Enable scrobbling of selected tracks from the closest scrabble button (i.e. the medium button) instead of always having to scrobble to the top one.
- [ ] #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
<!-- AC:END -->