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
+1 -1
View File
@@ -779,7 +779,7 @@ Tareas:
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas |
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
| P7 | ⬜ | | |
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
| P8 | ⬜ | | |
| P9 | ⬜ | | |
| P10 | ⬜ | | |
+7 -16
View File
@@ -4,7 +4,7 @@
import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types';
import Artwork from './ui/Artwork.svelte';
import ListRow from './ui/ListRow.svelte';
import IconButton from './ui/IconButton.svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
@@ -92,26 +92,17 @@
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<a
<ListRow
href="/playlists/{playlist.id}"
class="flex items-center gap-2.75 rounded-control px-3 py-1.75 transition-colors duration-150 {active
? 'bg-surface-hover'
: 'hover:bg-hover'}"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
{active}
size="xs"
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="xs"
class="shrink-0 opacity-90"
class="px-3 py-1.75"
/>
<div class="min-w-0 flex-1">
<div class="truncate text-sm {active ? 'text-fg' : 'text-fg-2'}">
{playlist.name}
</div>
</div>
</a>
{:else}
<button
type="button"
@@ -1,10 +1,9 @@
<script lang="ts">
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import Artwork from '$lib/components/ui/Artwork.svelte';
import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import IconButton from '$lib/components/ui/IconButton.svelte';
import { pressable } from '$lib/actions/pressable';
import { fmtTime } from '$lib/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
@@ -31,24 +30,15 @@
{#if player.current.id}
<p class="mb-2.75 text-xs font-medium tracking-widest text-fg-3 uppercase">Reproduciendo</p>
<div class="mb-6 flex items-center gap-3 rounded-xl bg-accent-tint p-2.5">
<Artwork
trackIds={[player.current.id]}
size="sm"
alt={player.current.title}
class="shrink-0"
/>
<div class="min-w-0">
<div class="truncate text-sm text-accent-soft">
{player.current.title}
</div>
<ArtistLink
name={player.current.artist || '—'}
<div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity
trackId={player.current.id}
title={player.current.title}
artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-3"
active
/>
</div>
</div>
{/if}
<div class="mb-2.75 flex items-center justify-between gap-3">
@@ -69,25 +59,22 @@
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<div
role="button"
tabindex="0"
use:pressable={() => player.playQueueIndex(index)}
class="flex items-center gap-3 rounded-control p-2.25 text-left hover:bg-hover"
<ListRow
onclick={() => player.playQueueIndex(index)}
size="sm"
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
trackId={track.id}
class="p-2.25"
>
<Artwork trackIds={[track.id]} size="sm" alt={track.title} class="shrink-0 opacity-80" />
<div class="min-w-0 flex-1">
<div class="truncate text-xs text-fg">{track.title}</div>
<ArtistLink
name={track.artist || '—'}
ownerUserId={track.ownerUserId}
class="mt-0.5 text-xs text-fg-3"
/>
</div>
{#snippet trailing()}
{#if track.duration}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span>
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
>
{/if}
</div>
{/snippet}
</ListRow>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each}
@@ -21,7 +21,7 @@
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import ChevronRight from '@lucide/svelte/icons/chevron-right';
import Artwork from './Artwork.svelte';
import MediaIdentity from './MediaIdentity.svelte';
import GlassMenu from './GlassMenu.svelte';
import MenuItem from './MenuItem.svelte';
@@ -93,14 +93,14 @@
{/each}
<button
type="submit"
class="flex w-full items-center gap-3 rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
class="flex w-full items-center rounded-control px-2 py-2.5 text-left text-sm font-medium text-fg transition hover:bg-hover"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=small"
<MediaIdentity
title={playlist.name}
coverSrc="/api/playlists/{playlist.id}/cover?size=small"
size="xs"
class="shrink-0"
titleClass="text-fg-2"
/>
<span class="truncate text-fg-2">{playlist.name}</span>
</button>
</form>
{/each}
+23 -3
View File
@@ -3,15 +3,34 @@
size?: number;
barWidth?: number;
paused?: boolean;
overlay?: boolean;
class?: string;
}
let { size = 13, barWidth = 2, paused = false, class: klass = '' }: Props = $props();
let {
size = 13,
barWidth = 2,
paused = false,
overlay = false,
class: klass = ''
}: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#if overlay}
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5 {klass}">
{#each delays as delay (delay)}
<span
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
{:else}
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="rounded-full bg-accent"
@@ -20,4 +39,5 @@
: 'running'}"
></span>
{/each}
</div>
</div>
{/if}
@@ -0,0 +1,90 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import MediaIdentity from './MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
interface Props {
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
active?: boolean;
size?: 'xs' | 'sm' | 'lg';
variant?: 'row' | 'card';
title: string;
subtitle?: string | null;
subtitleHref?: string | number | null;
explicit?: boolean;
trackId?: string | number;
trackIds?: (string | number)[];
coverSrc?: string | null;
art?: Snippet;
overlay?: Snippet;
leading?: Snippet;
trailing?: Snippet;
class?: string;
style?: string;
}
let {
href,
onclick,
oncontextmenu,
active = false,
size = 'sm',
variant = 'row',
title,
subtitle,
subtitleHref,
explicit = false,
trackId,
trackIds,
coverSrc,
art,
overlay,
leading,
trailing,
class: klass = '',
style
}: Props = $props();
const classes = $derived(
`flex items-center gap-3 text-left transition-colors ${
variant === 'card' ? 'rounded-art p-2.25 pr-3.5' : 'rounded-control p-2'
} ${active ? 'bg-accent-tint' : 'hover:bg-hover'} ${klass}`
);
</script>
{#snippet content()}
{@render leading?.()}
<MediaIdentity
{title}
artist={subtitle}
ownerUserId={subtitleHref}
{trackId}
{trackIds}
{coverSrc}
{size}
{explicit}
{active}
{art}
{overlay}
/>
{@render trailing?.()}
{/snippet}
{#if href}
<a {href} {oncontextmenu} class={classes} {style}>
{@render content()}
</a>
{:else}
<div
role="button"
tabindex="0"
use:pressable={onclick ?? (() => {})}
{oncontextmenu}
class={classes}
{style}
>
{@render content()}
</div>
{/if}
@@ -3,25 +3,27 @@
import Artwork from './Artwork.svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
import ArtistLink from './ArtistLink.svelte';
import { pressable } from '$lib/actions/pressable';
interface Props {
trackId: string | number;
trackId?: string | number;
trackIds?: (string | number)[];
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
coverSrc?: string | null;
size?: 'sm' | 'lg';
size?: 'xs' | 'sm' | 'lg';
explicit?: boolean;
active?: boolean;
titleClass?: string;
onClick: () => void;
class?: string;
art?: Snippet;
overlay?: Snippet;
badge?: Snippet;
}
let {
trackId,
trackIds,
title,
artist,
ownerUserId,
@@ -30,23 +32,27 @@
explicit = false,
active = false,
titleClass = '',
onClick,
class: klass = '',
art,
overlay,
badge
}: Props = $props();
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={onClick}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 items-center gap-3.5 text-left"
>
<Artwork trackIds={[trackId]} src={coverSrc} {size} alt={title} class="shrink-0">
<div class="flex min-w-0 flex-1 items-center gap-3.5 {klass}">
{#if art}
{@render art()}
{:else if trackId !== undefined || coverSrc || trackIds?.length}
<Artwork
trackIds={trackIds ?? (trackId !== undefined ? [trackId] : [])}
src={coverSrc}
{size}
alt={title}
class="shrink-0"
>
{@render overlay?.()}
</Artwork>
{/if}
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-2">
{#if explicit}
@@ -1,20 +0,0 @@
<script lang="ts">
interface Props {
paused?: boolean;
}
let { paused = false }: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
<div class="absolute inset-0 flex items-end justify-center gap-0.75 bg-scrim/45 pb-2.5">
{#each delays as delay (delay)}
<span
class="h-3.5 w-0.75 origin-bottom rounded-full bg-on-art"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
{/each}
</div>
@@ -1,65 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import ExplicitBadge from './ExplicitBadge.svelte';
interface Props {
title: string;
subtitle?: string | null;
meta?: string;
explicit?: boolean;
href?: string;
onclick?: () => void;
oncontextmenu?: (event: MouseEvent) => void;
art: Snippet<[string]>;
}
let {
title,
subtitle,
meta,
explicit = false,
href,
onclick,
oncontextmenu,
art
}: Props = $props();
const artClass = 'relative shrink-0';
</script>
{#snippet content()}
{@render art(artClass)}
<div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.75">
{#if explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-sm font-medium text-fg">{title}</span>
</div>
{#if subtitle}
<div class="mt-0.75 truncate text-xs text-fg-3">{subtitle}</div>
{/if}
</div>
{#if meta}
<span class="shrink-0 text-xs text-muted tabular-nums">{meta}</span>
{/if}
{/snippet}
{#if href}
<a
{href}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface"
>
{@render content()}
</a>
{:else}
<button
type="button"
{onclick}
{oncontextmenu}
class="group/row flex w-full min-w-0 items-center gap-3.5 rounded-control p-2 pr-4 text-left transition hover:bg-surface focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none"
>
{@render content()}
</button>
{/if}
@@ -8,7 +8,8 @@
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
type AlbumTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
@@ -34,15 +35,23 @@
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => playFrom(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
</div>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
{#if isOwner}
@@ -8,8 +8,9 @@
import TrackTable from '$lib/components/ui/TrackTable.svelte';
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
import EqBars from '$lib/components/ui/EqBars.svelte';
type LibraryTrack = ApiTrackLike & { duration: number | string; listensCount: number | string };
@@ -30,21 +31,29 @@
{#each library as track, i (track.id)}
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackTitleCell
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => playFrom(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
>
{#snippet overlay()}
{#if player.current.id === track.id}
<NowPlaying paused={!player.playing} />
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</TrackTitleCell>
</MediaIdentity>
</div>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
<TrackMeta>{fmtTime(Number(track.duration))}</TrackMeta>
<form
@@ -11,8 +11,8 @@
import Artwork from '$lib/components/ui/Artwork.svelte';
import Avatar from '$lib/components/ui/Avatar.svelte';
import PlayButton from '$lib/components/ui/PlayButton.svelte';
import NowPlaying from '$lib/components/ui/NowPlaying.svelte';
import SearchResultRow from '$lib/components/ui/SearchResultRow.svelte';
import EqBars from '$lib/components/ui/EqBars.svelte';
import ListRow from '$lib/components/ui/ListRow.svelte';
import SectionHeading from '$lib/components/ui/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { searchHref } from '$lib/navigation.svelte';
@@ -30,6 +30,7 @@
targetExplicit,
targetId,
targetListensCount,
targetOwnerUserId,
targetTitle,
type TrackTarget
} from '$lib/tracks';
@@ -410,27 +411,28 @@
<ul class="flex flex-col gap-1">
{#each capped(songRows) as { target: item, seconds }, i (targetId(item))}
<li>
<SearchResultRow
<ListRow
title={targetTitle(item)}
subtitle={targetArtist(item)}
meta="{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}"
subtitleHref={targetOwnerUserId(item)}
explicit={targetExplicit(item)}
active={isTargetCurrent(item)}
size="lg"
trackId={targetId(item)}
onclick={() => togglePlay(i)}
oncontextmenu={(e) => openContextMenu(e, item)}
>
{#snippet art(artClass)}
<Artwork
trackIds={[targetId(item)]}
size="lg"
alt={targetTitle(item)}
class="{artClass} ring-1 ring-line ring-inset"
>
{#snippet overlay()}
{#if isTargetCurrent(item)}
<NowPlaying paused={!player.playing} />
<EqBars overlay paused={!player.playing} />
{/if}
</Artwork>
{/snippet}
</SearchResultRow>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtTime(seconds)} · {fmtPlays(targetListensCount(item))}
</span>
{/snippet}
</ListRow>
</li>
{/each}
</ul>
@@ -460,16 +462,19 @@
<ul class="flex flex-col gap-1">
{#each capped(albums) as album (album.id)}
<li>
<SearchResultRow
<ListRow
title={album.title}
meta="{Number(album.trackCount)} canciones"
href="/albums/{album.id}"
size="lg"
trackIds={album.coverTrackIds}
oncontextmenu={(e) => openAlbumMenu(e, album.id)}
>
{#snippet art(artClass)}
<Artwork trackIds={album.coverTrackIds} size="lg" class={artClass} />
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{Number(album.trackCount)} canciones
</span>
{/snippet}
</SearchResultRow>
</ListRow>
</li>
{/each}
</ul>
@@ -489,11 +494,11 @@
<ul class="flex flex-col gap-1">
{#each capped(users) as u (u.id)}
<li>
<SearchResultRow title={u.name} href="/u/{u.id}">
{#snippet art(artClass)}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" class={artClass} />
<ListRow title={u.name} href="/u/{u.id}" size="lg">
{#snippet art()}
<Avatar name={u.name} src={u.profilePictureUrl} size="md" />
{/snippet}
</SearchResultRow>
</ListRow>
</li>
{/each}
</ul>
@@ -513,22 +518,16 @@
<ul class="flex flex-col gap-1">
{#each capped(playlistMatches) as playlist (playlist.id)}
<li>
<SearchResultRow
<ListRow
title={playlist.name}
subtitle={playlist.description}
href="/playlists/{playlist.id}"
>
{#snippet art(artClass)}
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
size="lg"
coverSrc="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
playlist.updatedAt
)}"
trackIds={playlist.coverTrackIds}
size="lg"
class={artClass}
/>
{/snippet}
</SearchResultRow>
</li>
{/each}
</ul>
@@ -4,7 +4,7 @@
import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import { player, toQueueItems } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import { fmtTime, fmtDate, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
@@ -14,7 +14,8 @@
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
let { data } = $props();
@@ -34,7 +35,7 @@
<Page>
<PageHeader
title="Tus canciones subidas"
subtitle="{total} {total === 1 ? 'canción' : 'canciones'} subida{total === 1 ? '' : 's'}."
subtitle="{plural(total, 'canción', 'canciones')} subida{total === 1 ? '' : 's'}."
>
{#snippet actions()}
{#if items.length > 0}
@@ -66,12 +67,16 @@
playing={player.playing}
onToggle={() => togglePlay(i)}
/>
<TrackTitleCell
trackId={track.id}
title={track.title}
{active}
onClick={() => togglePlay(i)}
/>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => togglePlay(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity trackId={track.id} title={track.title} {active} />
</div>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
+14 -5
View File
@@ -3,7 +3,7 @@
import X from '@lucide/svelte/icons/x';
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte';
import { fmtDate, fmtTime } from '$lib/format';
import { fmtDate, fmtTime, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -13,7 +13,8 @@
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
const tracks = $derived(liked.list);
const isCurrentQueue = $derived(isQueueCurrent(tracks));
@@ -36,7 +37,7 @@
<CollectionHeader
eyebrow="Colección"
title="Me gusta"
meta="{tracks.length} {tracks.length === 1 ? 'Canción' : 'Canciones'}"
meta={plural(tracks.length, 'canción', 'canciones')}
align="center"
>
{#snippet cover()}
@@ -73,15 +74,23 @@
playing={player.playing}
onToggle={() => playFrom(i)}
/>
<TrackTitleCell
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => playFrom(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
{active}
onClick={() => playFrom(i)}
/>
</div>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.likedAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount ?? 0)}</TrackMeta>
@@ -3,7 +3,7 @@
import Pause from '@lucide/svelte/icons/pause';
import Shuffle from '@lucide/svelte/icons/shuffle';
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import { fmtTime } from '$lib/format';
import { fmtTime, plural } from '$lib/format';
import Page from '$lib/components/ui/Page.svelte';
import BackLink from '$lib/components/ui/BackLink.svelte';
import Button from '$lib/components/ui/Button.svelte';
@@ -14,7 +14,8 @@
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/menus.svelte';
@@ -59,7 +60,7 @@
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
meta="{items.length} {items.length === 1 ? 'canción' : 'canciones'} · {fmtTime(totalSeconds)}"
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
>
{#snippet cover()}
<Artwork
@@ -100,13 +101,21 @@
{@const active = isTargetCurrent(targets[i])}
<TrackRow {active} oncontextmenu={(e) => trackMenu.open(e, targets[i])}>
<TrackIndexCell index={i} {active} playing={player.playing} onToggle={() => playFrom(i)} />
<TrackTitleCell
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => playFrom(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={targetId(targets[i])}
title={targetTitle(targets[i])}
artist={targetArtist(targets[i])}
{active}
onClick={() => playFrom(i)}
/>
</div>
<TrackMeta class="hidden sm:block">{Number(targetListensCount(targets[i]) ?? 0)}</TrackMeta>
<TrackMeta>{fmtTime(Number(item.durationSeconds))}</TrackMeta>
</TrackRow>
@@ -7,14 +7,14 @@
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import Button from '$lib/components/ui/Button.svelte';
import { createPlaylistModal } from '$lib/playlists.svelte';
import { fmtDurationLong } from '$lib/format';
import { fmtDurationLong, plural } from '$lib/format';
let { data } = $props();
const items = $derived(data.playlists.items);
const totals = $derived(data.totals);
const summary = $derived(
`${totals.playlistCount} ${totals.playlistCount === 1 ? 'Playlist' : 'Playlists'} · ${totals.trackCount} ${totals.trackCount === 1 ? 'Canción' : 'Canciones'} · ${fmtDurationLong(totals.durationSeconds)}`
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
);
</script>
@@ -2,6 +2,7 @@
import Artwork from '$lib/components/ui/Artwork.svelte';
import Button from '$lib/components/ui/Button.svelte';
import CollectionHeader from '$lib/components/ui/CollectionHeader.svelte';
import { plural } from '$lib/format';
interface Props {
id: string;
@@ -30,7 +31,7 @@
}: Props = $props();
const meta = $derived(
`${trackIds.length} ${trackIds.length === 1 ? 'Canción' : 'Canciones'} · ${
`${plural(trackIds.length, 'canción', 'canciones')} · ${
visibility === 'Public' ? 'Pública' : 'Privada'
}`
);
@@ -8,7 +8,8 @@
import TrackRow from '$lib/components/ui/TrackRow.svelte';
import TrackMeta from '$lib/components/ui/TrackMeta.svelte';
import TrackIndexCell from '$lib/components/ui/TrackIndexCell.svelte';
import TrackTitleCell from '$lib/components/ui/TrackTitleCell.svelte';
import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import { pressable } from '$lib/actions/pressable';
type PlaylistTrack = ApiTrackLike & {
createdAt: string;
@@ -41,15 +42,23 @@
{@const active = player.current.id === track.id}
<TrackRow {active}>
<TrackIndexCell index={i} playing={player.playing} onToggle={() => playFrom(i)} {active} />
<TrackTitleCell
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => playFrom(i)}
onclick={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
trackId={track.id}
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.isExplicit}
{active}
onClick={() => playFrom(i)}
/>
</div>
<TrackMeta class="hidden sm:block">—</TrackMeta>
<TrackMeta class="hidden sm:block">{fmtDate(track.createdAt)}</TrackMeta>
<TrackMeta class="hidden sm:block">{Number(track.listensCount)}</TrackMeta>
+48 -90
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)}
size="lg"
variant="card"
title={item.title}
subtitle={item.artist}
subtitleHref={item.ownerUserId}
trackId={item.id}
class="animate-enter"
style="--i:{i}"
/>
</div>
{/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>
{/snippet}
{#snippet trailing()}
<span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(seconds)}</span>
</div>
{/snippet}
</ListRow>
{:else}
<p class="p-2 text-sm text-fg-3">Esta playlist todavía no tiene canciones.</p>
{/each}
@@ -314,15 +295,18 @@
<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)}
>
{#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} />
@@ -330,21 +314,13 @@
<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>
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-muted tabular-nums sm:block">
{fmtPlays(targetListensCount(target))}
</span>
</div>
{/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"
style="--i:{i}"
>
<Artwork
src="/api/playlists/{playlist.id}/cover?size=medium&v={encodeURIComponent(
<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}
size="lg"
alt={playlist.name}
class="shrink-0"
style="--i:{i}"
/>
<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}