web-player: rows (ListRow, MediaIdentity, EqBars)

Add MediaIdentity.svelte (renames and generalizes TrackTitleCell,
purely presentational: art + title + subtitle, no click handling)
and ListRow.svelte on top of it (href|onclick, active, size xs/sm/lg,
variant row|card, leading/trailing/overlay). Also used by
player/TrackInfo... (TrackInfo itself is left for a later pass, which
already has an explicit task to touch that file).

Migrated to ListRow: SearchResultRow (Explore, 4 groups — component
removed), Home (Continue listening, Popular, spotlight, Your
playlists), Queue (Up next), Sidebar (playlists). The 6 track tables
(AlbumTrackTable, PlaylistTrackTable, library, liked, mixes,
LibraryPicker) migrate to MediaIdentity with their own local
use:pressable, since TrackList doesn't exist yet.

EqBars gains an `overlay` prop (scrim/45 background, on-art color)
replacing NowPlaying.svelte (removed). Along the way, fixes 6
plural() calls that a previous pass had left unmigrated (liked,
library, mixes, playlists, PlaylistHeader, Home).

Deltas documented in the plan's log: Sidebar's active row now uses
the accent tint (previously neutral gray); Home's "Your playlists"
card loses its redundant author line and its radius becomes
rounded-art; Queue rows lose the art's opacity-80.

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-21 00:14:20 +02:00
parent 14ee1dc45d
commit fb4a8ccc0e
19 changed files with 407 additions and 390 deletions
+64 -106
View File
@@ -6,7 +6,7 @@
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import { pressable } from '$lib/actions/pressable';
import { mergeRecentlyPlayed } from '$lib/recentlyPlayed';
import { fmtTime, fmtDurationLong, fmtPlays } from '$lib/format';
import { fmtTime, fmtDurationLong, fmtPlays, plural } from '$lib/format';
import {
isTargetCurrent,
queueItemForTarget,
@@ -19,11 +19,10 @@
targetTitle,
type TrackTarget
} from '$lib/tracks';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import MixTile from '$lib/components/ui/MixTile.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -72,7 +71,7 @@
spotlightTrackCount === 0
? spotlightVisibilityLabel
: [
`${spotlightTrackCount} ${spotlightTrackCount === 1 ? 'Canción' : 'Canciones'}`,
plural(spotlightTrackCount, 'canción', 'canciones'),
fmtDurationLong(spotlightDurationSeconds),
spotlightVisibilityLabel
].join(' · ')
@@ -176,26 +175,19 @@
<div class="grid grid-cols-2 gap-2.5 sm:grid-cols-3 lg:grid-cols-5">
{#each continueItems as item, i (item.id)}
{@const isCurrent = player.current.id === item.id}
<div
role="button"
tabindex="0"
use:pressable={() => playContinue(i)}
class="animate-enter rounded-art p-2.25 pr-3.5 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
style="--i:{i}"
<ListRow
onclick={() => playContinue(i)}
oncontextmenu={(e) => openContextMenu(e, targetForQueueItem(item))}
>
<TrackTitleCell
trackId={item.id}
title={item.title}
artist={item.artist}
ownerUserId={item.ownerUserId}
size="lg"
active={isCurrent}
onClick={() => playContinue(i)}
/>
</div>
active={isCurrent}
size="lg"
variant="card"
title={item.title}
subtitle={item.artist}
subtitleHref={item.ownerUserId}
trackId={item.id}
class="animate-enter"
style="--i:{i}"
/>
{/each}
</div>
{:else}
@@ -254,33 +246,22 @@
</div>
<div class="flex flex-col gap-0.5 p-5.5 sm:p-6">
{#each spotlightRows as { target, seconds }, i (targetId(target))}
<div
role="button"
tabindex="0"
use:pressable={() => playSpotlightTrack(i)}
class="flex items-center gap-4 rounded-control p-2 text-left transition-colors {isTargetCurrent(
target
)
? 'bg-accent-tint'
: 'hover:bg-hover'}"
<ListRow
onclick={() => playSpotlightTrack(i)}
active={isTargetCurrent(target)}
size="sm"
title={targetTitle(target)}
subtitle={targetArtist(target)}
subtitleHref={targetOwnerUserId(target)}
>
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span>
<div class="min-w-0 flex-1">
<div
class="truncate text-sm {isTargetCurrent(target)
? 'text-accent-soft'
: 'text-fg'}"
{#snippet leading()}
<span class="w-5 shrink-0 text-center text-xs text-muted tabular-nums">{i + 1}</span
>
{targetTitle(target)}
</div>
<ArtistLink
name={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
class="mt-0.5 text-xs text-fg-3"
/>
</div>
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
</div>
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
{/snippet}
</ListRow>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
{/each}
@@ -314,37 +295,32 @@
<div class="grid grid-cols-1 gap-x-8.5 gap-y-1.5 lg:grid-cols-2">
{#each popularTargets as target, i (targetId(target))}
{@const isCurrent = isTargetCurrent(target)}
<div
role="button"
tabindex="0"
use:pressable={() => playPopular(i)}
class="flex items-center gap-3.5 rounded-control p-2 transition-colors {isCurrent
? 'bg-accent-tint'
: 'hover:bg-hover'}"
<ListRow
onclick={() => playPopular(i)}
oncontextmenu={(e) => openContextMenu(e, target)}
active={isCurrent}
size="sm"
title={targetTitle(target)}
subtitle={targetArtist(target)}
subtitleHref={targetOwnerUserId(target)}
explicit={targetExplicit(target)}
trackId={targetId(target)}
>
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</span>
<div class="min-w-0 flex-1">
<TrackTitleCell
trackId={targetId(target)}
title={targetTitle(target)}
artist={targetArtist(target)}
ownerUserId={targetOwnerUserId(target)}
explicit={targetExplicit(target)}
active={isCurrent}
onClick={() => playPopular(i)}
/>
</div>
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtPlays(targetListensCount(target))}
</span>
</div>
{#snippet leading()}
<span class="flex h-3.5 w-4.5 shrink-0 items-end justify-center">
{#if isCurrent}
<EqBars size={13} paused={!player.playing} />
{:else}
<span class="text-xs text-muted tabular-nums">{i + 1}</span>
{/if}
</span>
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtPlays(targetListensCount(target))}
</span>
{/snippet}
</ListRow>
{/each}
</div>
{:else}
@@ -377,36 +353,18 @@
{#if playlists.length > 0}
<div class="grid grid-cols-[repeat(auto-fill,minmax(258px,1fr))] gap-3.5">
{#each playlists as playlist, i (playlist.id)}
<div
role="button"
tabindex="0"
use:pressable={() => goto(`/playlists/${playlist.id}`)}
class="group/card animate-enter flex items-center gap-3.5 rounded-panel p-3 transition-colors hover:bg-hover"
<ListRow
onclick={() => goto(`/playlists/${playlist.id}`)}
size="lg"
variant="card"
title={playlist.name}
subtitle={playlist.description}
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
style="--i:{i}"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="lg"
alt={playlist.name}
class="shrink-0"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm tracking-tight text-fg">
{playlist.name}
</div>
{#if playlist.description}
<div class="mt-1 truncate text-xs text-fg-3">{playlist.description}</div>
{/if}
<ArtistLink
name={data.user?.name}
ownerUserId={data.user?.sub}
class="mt-1 text-xs text-fg-3"
/>
</div>
</div>
/>
{/each}
</div>
{:else}