feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
release / govulncheck (push) Successful in 34s
release / web (push) Failing after 1m20s
release / go (push) Successful in 1m37s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m38s
release / android (push) Canceled after 4m39s
release / Build signed APK (releases and dev) (push) Canceled after 3m49s

Every track list (album, Liked, Search, Search → Tracks, playlist,
History) now draws its rows with TrackListRow inside a TrackList box,
in place of four row components that had drifted apart:

- TrackListRow owns the look: lead column, title over "artist · album",
  like, radio, add to queue, menu, duration, then a trailing slot. It
  also owns the stripe, hover, the now-playing band and the
  unavailable state. The stripe is dropped on now-playing and selected
  rows, where Tailwind would otherwise let `odd:` win.
- TrackRow, PlaylistTrackRow and HistoryRow are wrappers that keep only
  their behaviour: track number and multi-select; grip, remove and
  dead-entry states; cover and timestamp. Playlist and History rows
  gain radio, add and the menu, so every list offers the same actions.
- The radio control is Lucide's Radio icon, not the 📻 emoji.
- Enter on a control inside a row now belongs to that control. The old
  TrackRow played the track when Enter was pressed on "add to queue".
- TrackList has no fill, so the stripe shows on playlists too.

Drag reorder (#5393): neodrag kept the drop offset, so after the list
re-rendered the dragged row sat between two slots. reorderDrag, shared
by the playlist and queue rows:
- snaps the row to whole rows while dragging;
- resets it to its slot on drop;
- on playlists, gives the touch gesture to the grip only, so the list
  still scrolls by touch.
The playlist page reorders optimistically and rolls back on error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 14:14:06 -04:00
co-authored by Claude Opus 5.5
parent e16d182417
commit b7f50c42a9
20 changed files with 666 additions and 241 deletions
+4 -3
View File
@@ -5,6 +5,7 @@
import { qk } from '#lib/api/queries.js';
import { pageTitle } from '#lib/branding.js';
import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import LikeButton from '#lib/components/LikeButton.svelte';
@@ -199,11 +200,11 @@
{#if album.tracks.length === 0}
<p class="text-text-secondary">This album has no tracks.</p>
{:else}
<div class="overflow-hidden rounded border border-border">
<TrackList>
{#each album.tracks as track, i (track.id)}
<TrackRow tracks={album.tracks} index={i} />
<TrackRow tracks={album.tracks} index={i} showAlbum={false} />
{/each}
</div>
</TrackList>
{/if}
{/if}
</div>
+3 -2
View File
@@ -3,6 +3,7 @@
import { createHistoryQuery } from '#lib/api/history.js';
import { groupByDay } from '#lib/utils/dayGroup.js';
import HistoryRow from '#lib/components/HistoryRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import InfiniteScrollSentinel from '#lib/components/InfiniteScrollSentinel.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
@@ -62,11 +63,11 @@
<h2 class="sticky top-0 z-10 mb-2 bg-background py-1 text-lg font-medium text-text-secondary">
{group.label}
</h2>
<div class="divide-y divide-border">
<TrackList>
{#each group.events as event (event.id)}
<HistoryRow {event} />
{/each}
</div>
</TrackList>
</section>
{/each}
+12 -1
View File
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/svelte';
import HistoryPage from './+page.svelte';
import type { HistoryEvent, HistoryResponse } from '#lib/api/history.js';
import { makeTrack } from '#test-utils/fixtures/track.js';
import { emptyLikesMock } from '../../../test-utils/mocks/likes';
import { emptyQuarantineMock } from '../../../test-utils/mocks/quarantine';
const mkTrack = (id: string) => makeTrack({ id: `track-${id}`, title: `Song ${id}` });
@@ -44,10 +46,19 @@ vi.mock('#lib/api/history.js', async () => {
};
});
// Rows are TrackListRows, so LikeButton and TrackMenu render too.
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn()
playQueue: vi.fn(),
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined }
}));
vi.mock('#lib/api/likes.js', () => emptyLikesMock());
vi.mock('#lib/api/quarantine.js', () => emptyQuarantineMock());
vi.mock('#lib/branding.js', () => ({
pageTitle: (s: string) => `Minstrel · ${s}`
}));
+3 -2
View File
@@ -8,6 +8,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlbumCard from '#lib/components/AlbumCard.svelte';
import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import QuickFilter from '#lib/components/QuickFilter.svelte';
import EmptyState from '#lib/components/EmptyState.svelte';
@@ -157,11 +158,11 @@
{/snippet}
</EmptyState>
{:else}
<div class="overflow-hidden rounded border border-border">
<TrackList>
{#each fTracks as t, i (t.id)}
<TrackRow tracks={fTracks} index={i} />
{/each}
</div>
</TrackList>
{#if tracksQuery?.hasNextPage}
<button
type="button"
+1 -1
View File
@@ -21,7 +21,7 @@ vi.mock('#lib/api/likes.js', () => ({
vi.mock('#lib/api/client.js', () => apiClientMock());
vi.mock('#lib/player/store.svelte.js', () => ({
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(),
playQueue: vi.fn(), enqueueTrack: vi.fn(), enqueueTracks: vi.fn(), playRadio: vi.fn(),
player: { current: undefined }
}));
+26 -14
View File
@@ -6,6 +6,7 @@
import { Pencil, Trash2, Link as LinkIcon } from 'lucide-svelte';
import { useQueryClient } from '@tanstack/svelte-query';
import PlaylistTrackRow from '#lib/components/PlaylistTrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import {
createPlaylistQuery,
@@ -23,7 +24,7 @@
import { systemPlaylistRefetch } from '#lib/playlists/systemRefetch.js';
import { systemRebuilt } from '#lib/serverEvents.svelte.js';
import { pushToast } from '#lib/stores/toast.svelte.js';
import type { TrackRef } from '#lib/api/types.js';
import type { PlaylistDetail, TrackRef } from '#lib/api/types.js';
// SvelteKit types page.params.id as string | undefined (the route
// could in principle render with the slot empty); empty-string fallback
@@ -45,12 +46,23 @@
const cur = tracks.map((t) => t.position);
const moved = cur.splice(fromPos, 1)[0];
cur.splice(clamped, 0, moved);
// Show the new order on drop rather than after the round trip, so the
// dragged row lands in its slot at once (#5393). Positions are
// renumbered the way the server renumbers them; the invalidate below
// replaces this with the server's copy either way.
const previous = playlistQuery.data;
const byPosition = new Map(tracks.map((t) => [t.position, t]));
queryClient.setQueryData<PlaylistDetail>(qk.playlist(id), {
...previous,
tracks: cur.map((pos, i) => ({ ...byPosition.get(pos)!, position: i }))
});
try {
await reorderPlaylist(id, cur);
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
} catch (e: unknown) {
queryClient.setQueryData(qk.playlist(id), previous);
alert(errMessage(e));
}
await queryClient.invalidateQueries({ queryKey: qk.playlist(id) });
}
async function onRemove(position: number) {
@@ -342,16 +354,16 @@
</section>
{/if}
<section class="rounded-md border border-border bg-surface">
{#if pl.tracks.length === 0}
<p class="px-3 py-6 text-sm text-text-muted">
{#if isOwner}
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
{:else}
No tracks in this playlist.
{/if}
</p>
{:else}
{#if pl.tracks.length === 0}
<p class="rounded-md border border-border px-3 py-6 text-sm text-text-muted">
{#if isOwner}
No tracks yet. Add some via the "Add to playlist…" entry on any track row.
{:else}
No tracks in this playlist.
{/if}
</p>
{:else}
<TrackList>
{#each pl.tracks as row (row.position)}
<PlaylistTrackRow
{row}
@@ -361,7 +373,7 @@
onMove={onMove}
/>
{/each}
{/if}
</section>
</TrackList>
{/if}
{/if}
</div>
@@ -31,6 +31,7 @@ vi.mock('#lib/player/store.svelte.js', () => ({
enqueueTracks: vi.fn(),
playNext: vi.fn(),
enqueueTrack: vi.fn(),
playRadio: vi.fn(),
player: { current: undefined }
}));
+3 -2
View File
@@ -5,6 +5,7 @@
import ArtistCard from '#lib/components/ArtistCard.svelte';
import AlbumCard from '#lib/components/AlbumCard.svelte';
import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import SearchSkeleton from '#lib/components/SearchSkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -104,11 +105,11 @@
</a>
{/if}
</header>
<div class="overflow-hidden rounded border border-border">
<TrackList>
{#each tracks.items as t, i (t.id)}
<TrackRow tracks={tracks.items} index={i} onPlay={onTrackPlay} />
{/each}
</div>
</TrackList>
</section>
{/if}
{/if}
+4 -3
View File
@@ -3,6 +3,7 @@
import { page } from '$app/state';
import { createSearchTracksInfiniteQuery } from '#lib/api/queries.js';
import TrackRow from '#lib/components/TrackRow.svelte';
import TrackList from '#lib/components/TrackList.svelte';
import LibrarySkeleton from '#lib/components/LibrarySkeleton.svelte';
import ApiErrorBanner from '#lib/components/ApiErrorBanner.svelte';
import { useDelayed } from '#lib/utils/useDelayed.svelte.js';
@@ -29,7 +30,7 @@
<a href={`/search?q=${encodeURIComponent(q)}`} class="text-sm text-text-secondary hover:text-text-primary">
← Back to search
</a>
<h1 class="mt-2 text-2xl font-semibold">Tracks matching '{q}'</h1>
<h1 class="mt-2 font-display text-2xl font-medium text-text-primary">Tracks matching '{q}'</h1>
{#if !query?.isPending && !query?.isError && q}
<p class="text-sm text-text-secondary">{total} {total === 1 ? 'track' : 'tracks'}</p>
{/if}
@@ -44,11 +45,11 @@
{:else if total === 0}
<p class="text-text-secondary">No track matches.</p>
{:else}
<div class="overflow-hidden rounded border border-border">
<TrackList>
{#each tracks as t, i (t.id)}
<TrackRow tracks={tracks} index={i} onPlay={onTrackPlay} />
{/each}
</div>
</TrackList>
{#if query?.hasNextPage}
<button
type="button"