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
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:
@@ -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,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}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}));
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user