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
+64 -97
View File
@@ -1,12 +1,10 @@
<script lang="ts">
// A track in a playlist. The look is TrackListRow's; what this adds is
// the reorder grip, remove, and the two ways an entry can be dead.
import { GripVertical, X } from 'lucide-svelte';
import { draggable, type DragEventData } from '@neodrag/svelte';
import LikeButton from './LikeButton.svelte';
import TrackMenu from './TrackMenu.svelte';
import type { PlaylistTrack } from '#lib/api/types.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import { player } from '#lib/player/store.svelte.js';
import { offsetToDelta } from './queue-row-math';
import TrackListRow from './TrackListRow.svelte';
let {
row,
@@ -32,22 +30,21 @@
const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile);
// Reconstruct a minimal TrackRef for the kebab menu when the
// upstream track still exists. When unavailable, the menu is hidden.
// null when the entry cannot be played, which hides like, radio, add
// and the menu.
const liveTrack = $derived(playlistTrackToRef(row));
let measuredRowHeight = 56; // sensible default; replaced on dragStart
const canDrag = $derived(isOwner && !isUnavailable);
function handleDragStart(data: DragEventData) {
const rect = data.rootNode.getBoundingClientRect();
if (rect.height > 0) measuredRowHeight = rect.height;
}
function handleDragEnd(data: DragEventData) {
const delta = offsetToDelta(data.offsetY, measuredRowHeight);
if (delta === 0) return;
onMove?.(row.position, row.position + delta);
}
const drag = $derived(
isOwner
? {
disabled: !canDrag,
handle: '[data-reorder-handle]',
onMove: (delta: number) => onMove?.(row.position, row.position + delta)
}
: null
);
function handleHandleKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowUp') {
@@ -63,87 +60,57 @@
e.preventDefault();
}
}
function format(sec: number): string {
const m = Math.floor(sec / 60);
const s = sec % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
// Drag is gated on owner+available. When neither, the action runs but
// the dragEnd handler skips by returning before onMove fires; visually
// the user never sees the row move.
const canDrag = $derived(isOwner && !isUnavailable);
// "Now playing" highlight: matches QueueTrackRow's treatment so the
// user can see which row is current without reading the player bar.
// Unavailable rows (trackId === null) never match.
const isCurrent = $derived(
!isUnavailable && player.current?.id === row.track_id,
);
</script>
<div
role="listitem"
class="flex items-center gap-3 px-3 py-2 text-sm transition-colors hover:bg-surface-hover
{isUnavailable ? 'text-text-muted' : 'text-text-primary'}
{isCurrent ? 'border-l-2 border-l-accent bg-surface-hover' : ''}"
use:draggable={{
axis: 'y',
bounds: 'parent',
disabled: !canDrag,
onDragStart: handleDragStart,
onDragEnd: handleDragEnd
}}
<TrackListRow
title={row.title}
subtitle={`${row.artist_name} · ${row.album_title}`}
durationSec={row.duration_sec}
track={liveTrack}
ariaLabel={`Play ${row.title}`}
rowTitle={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
disabled={isUnavailable}
struck={isRemoved}
{drag}
onActivate={() => onPlay(row.position)}
>
{#if isOwner}
<button
type="button"
disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onkeydown={handleHandleKeydown}
class="shrink-0 cursor-grab text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical size={14} strokeWidth={1} />
</button>
{/if}
{#snippet lead()}
{#if isOwner}
<button
type="button"
data-reorder-handle
disabled={!canDrag}
aria-label="Reorder track (drag or use arrow keys)"
aria-keyshortcuts="ArrowUp ArrowDown"
onclick={(e) => e.stopPropagation()}
onkeydown={handleHandleKeydown}
class="cursor-grab p-1 text-text-muted hover:text-text-primary
active:cursor-grabbing focus-visible:outline-solid focus-visible:outline-2
focus-visible:outline-accent disabled:cursor-not-allowed disabled:opacity-50"
>
<GripVertical size={16} strokeWidth={1} />
</button>
{/if}
{/snippet}
<button
type="button"
class="min-w-0 flex-1 text-left"
onclick={() => !isUnavailable && onPlay(row.position)}
disabled={isUnavailable}
title={isMissingFile
? 'This track’s file is missing from the library, so it can’t be played. It stays in the playlist, and returns automatically if the file comes back.'
: undefined}
>
<div class="truncate {isRemoved ? 'line-through' : ''}">{row.title}</div>
<div class="truncate text-xs text-text-muted">
{row.artist_name} · {row.album_title}
{#if isMissingFile}
<span class="text-action-destructive"> · File missing</span>
{/if}
</div>
</button>
{#snippet note()}
{#if isMissingFile}
<span class="text-error-fg"> · File missing</span>
{/if}
{/snippet}
<span class="shrink-0 text-xs text-text-muted">{format(row.duration_sec)}</span>
{#if !isUnavailable && liveTrack}
<LikeButton entityType="track" entityId={liveTrack.id} />
<TrackMenu track={liveTrack} />
{/if}
{#if isOwner}
<button
type="button"
onclick={() => onRemove(row.position)}
aria-label={`Remove ${row.title} from playlist`}
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={14} strokeWidth={1} />
</button>
{/if}
</div>
{#snippet trailing()}
{#if isOwner}
<button
type="button"
onclick={(e) => { e.stopPropagation(); onRemove(row.position); }}
aria-label={`Remove ${row.title} from playlist`}
class="shrink-0 rounded p-1 text-text-muted hover:bg-surface hover:text-action-destructive"
>
<X size={16} strokeWidth={1} />
</button>
{/if}
{/snippet}
</TrackListRow>