web-player: track tables (TrackList)

Add TrackList.svelte, replacing TrackTable+TrackRow+TrackIndexCell+
TrackMeta and the 6 track tables (library, liked, mix, album,
playlist, album's library picker): declarative columns
(added/uploaded/plays) with fixed widths as module constants, an
index with an EQ indicator for the active track (or an overlay on the
art when index={false}, for the library picker), and a rowAction that
builds the form+IconButton (removeTrack/addTrack/deleteTrack) or a
direct onclick (Like).

AlbumTrackTable, PlaylistTrackTable and LibraryPicker are inlined
into their pages and removed, along with the 4 components TrackList
replaces.

Decision (recommended by the plan itself, not blocking): remove the
"Album" column, which in all 3 tables that had it always showed "—"
(a placeholder with no real data).

Verified with npm run check/build; no authenticated visual QA (Docker
unavailable this session).
This commit is contained in:
2026-09-21 00:21:15 +02:00
parent fb4a8ccc0e
commit 8cea298b2a
14 changed files with 285 additions and 535 deletions
@@ -1,27 +0,0 @@
<script lang="ts">
import Play from '@lucide/svelte/icons/play';
import EqBars from './EqBars.svelte';
interface Props {
index: number;
active: boolean;
playing: boolean;
onToggle: () => void;
}
let { index, active, playing, onToggle }: Props = $props();
</script>
<button
type="button"
onclick={onToggle}
aria-label={active && playing ? 'Pausar' : 'Reproducir'}
class="relative grid h-8 w-8 place-items-center rounded text-xs text-muted tabular-nums"
>
{#if active}
<EqBars paused={!playing} />
{:else}
<span class="group-hover:hidden">{index + 1}</span>
<Play class="hidden h-3.5 w-3.5 text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
@@ -0,0 +1,200 @@
<script lang="ts" module>
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export interface TrackListTrack {
id: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
isExplicit?: boolean;
explicit?: boolean;
duration?: number | string;
listensCount?: number | string;
addedAt?: string | number;
uploadedAt?: string | number;
}
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
added: { label: 'Añadida', width: '124px' },
uploaded: { label: 'Subida', width: '124px' },
plays: { label: 'Escuchas', width: '104px' }
};
const INDEX_W = { desktop: '32px', mobile: '28px' };
const TITLE_W = { desktop: 'minmax(180px,1fr)', mobile: 'minmax(120px,1fr)' };
const DURATION_W = { desktop: '104px', mobile: '52px' };
const ACTION_W = { desktop: '36px', mobile: '32px' };
</script>
<script lang="ts">
import { enhance } from '$app/forms';
import type { LucideIcon } from '@lucide/svelte';
import Hash from '@lucide/svelte/icons/hash';
import Play from '@lucide/svelte/icons/play';
import { player } from '$lib/player/player.svelte';
import { fmtTime, fmtDate } from '$lib/format';
import { pressable } from '$lib/actions/pressable';
import MediaIdentity from './MediaIdentity.svelte';
import EqBars from './EqBars.svelte';
import IconButton from './IconButton.svelte';
interface RowAction {
action?: string;
onclick?: (track: TrackListTrack) => void;
icon: LucideIcon;
label: string;
}
interface Props {
tracks: TrackListTrack[];
columns?: TrackListColumn[];
index?: boolean;
onPlay: (index: number) => void;
rowAction?: RowAction;
oncontextmenu?: (event: MouseEvent, track: TrackListTrack, index: number) => void;
class?: string;
}
let {
tracks,
columns = [],
index = true,
onPlay,
rowAction,
oncontextmenu,
class: klass = ''
}: Props = $props();
const gridColumns = $derived(
[
index ? INDEX_W.desktop : '',
TITLE_W.desktop,
...columns.map((c) => COLUMN_PRESET[c].width),
DURATION_W.desktop,
rowAction ? ACTION_W.desktop : ''
]
.filter(Boolean)
.join(' ')
);
const gridColumnsMobile = $derived(
[
index ? INDEX_W.mobile : '',
TITLE_W.mobile,
DURATION_W.mobile,
rowAction ? ACTION_W.mobile : ''
]
.filter(Boolean)
.join(' ')
);
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
{/if}
<span>Título</span>
{#each columns as column (column)}
<span class="hidden text-center sm:block">{COLUMN_PRESET[column].label}</span>
{/each}
<span class="hidden text-center sm:block">Duración</span>
{#if rowAction}
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col gap-0.5">
{#each tracks as track, i (track.id)}
{@const active = player.current.id === track.id}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint'
: 'hover:bg-hover'}"
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
oncontextmenu={oncontextmenu ? (event) => oncontextmenu(event, track, i) : undefined}
>
{#if index}
<button
type="button"
onclick={() => onPlay(i)}
aria-label={active && player.playing ? 'Pausar' : 'Reproducir'}
class="relative grid size-8 place-items-center rounded-tag text-xs text-muted tabular-nums"
>
{#if active}
<EqBars paused={!player.playing} />
{:else}
<span class="group-hover:hidden">{i + 1}</span>
<Play class="hidden size-icon-sm text-fg group-hover:block" fill="currentColor" />
{/if}
</button>
{/if}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div
role="button"
tabindex="0"
use:pressable={() => onPlay(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 ?? track.explicit ?? false}
{active}
>
{#snippet overlay()}
{#if !index && active}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
</MediaIdentity>
</div>
{#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-muted tabular-nums sm:block">
{#if column === 'plays'}
{Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt}
{fmtDate(track.addedAt)}
{:else if column === 'uploaded' && track.uploadedAt}
{fmtDate(track.uploadedAt)}
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-muted tabular-nums">
{fmtTime(Number(track.duration))}
</span>
{#if rowAction}
{#if rowAction.action}
<form
method="POST"
action={rowAction.action}
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
<rowAction.icon class="size-icon-sm" />
</IconButton>
</form>
{:else}
<IconButton
label={rowAction.label}
size="xs"
revealOnHover
onclick={() => rowAction.onclick?.(track)}
>
<rowAction.icon class="size-icon-sm" />
</IconButton>
{/if}
{/if}
</div>
{/each}
</div>
</div>
@@ -1,14 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children: Snippet;
}
let { class: klass = '', children }: Props = $props();
</script>
<span class="truncate text-center text-xs text-muted tabular-nums {klass}">
{@render children()}
</span>
@@ -1,27 +0,0 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Snippet } from 'svelte';
import { trackTableKey, type TrackTableContext } from './TrackTable.svelte';
interface Props {
active?: boolean;
class?: string;
oncontextmenu?: (event: MouseEvent) => void;
children: Snippet;
}
let { active = false, class: klass = '', oncontextmenu, children }: Props = $props();
const table = getContext<TrackTableContext>(trackTableKey);
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group grid track-grid items-center gap-3 rounded-control py-3 pr-4 pl-2 transition-colors sm:gap-8 sm:pr-6 sm:pl-3 {active
? 'bg-accent-tint'
: 'hover:bg-hover'} {klass}"
style="--mf-track-cols:{table.columns}; --mf-track-cols-mobile:{table.columnsMobile}"
{oncontextmenu}
>
{@render children()}
</div>
@@ -1,79 +0,0 @@
<script lang="ts" module>
export const trackTableKey = Symbol('track-table');
export interface TrackTableContext {
readonly columns: string;
readonly columnsMobile: string;
}
export interface TrackMetaColumn {
label: string;
width: string;
}
</script>
<script lang="ts">
import { Hash } from '@lucide/svelte';
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
index?: boolean;
action?: boolean;
meta?: TrackMetaColumn[];
class?: string;
children: Snippet;
}
let { index = false, action = false, meta = [], class: klass = '', children }: Props = $props();
const columns = $derived(
[
index ? '32px' : '',
'minmax(180px,1fr)',
...meta.map((column) => column.width),
'104px',
action ? '36px' : ''
]
.filter((width) => width !== '')
.join(' ')
);
const columnsMobile = $derived(
[index ? '28px' : '', 'minmax(120px,1fr)', '52px', action ? '32px' : '']
.filter((width) => width !== '')
.join(' ')
);
setContext<TrackTableContext>(trackTableKey, {
get columns() {
return columns;
},
get columnsMobile() {
return columnsMobile;
}
});
</script>
<div class="overflow-x-auto {klass}">
<div
class="grid track-grid items-center gap-3 border-b border-line pr-4 pb-3 pl-2 text-sm text-fg-3 sm:gap-8 sm:pr-6 sm:pl-3"
style="--mf-track-cols:{columns}; --mf-track-cols-mobile:{columnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
{/if}
<span class="transition-colors">Título</span>
{#each meta as column (column.label)}
<span class="hidden text-center transition-colors sm:block">{column.label}</span>
{/each}
<span class="hidden text-center transition-colors sm:block">Duración</span>
{#if action}
<span></span>
{/if}
</div>
<div class="mt-1 flex flex-col gap-0.5">
{@render children()}
</div>
</div>