Files
minstrel/web/src/lib/components/PlaylistTrackRow.svelte
T
bvandeusenandClaude Opus 5.5 b7f50c42a9
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
feat(web): one track-list row shape, and drag reorder that snaps (M494, #5393)
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>
2026-10-08 14:14:06 -04:00

117 lines
3.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 type { PlaylistTrack } from '#lib/api/types.js';
import { playlistTrackToRef } from '#lib/playlists/playlistTrackToRef.js';
import TrackListRow from './TrackListRow.svelte';
let {
row,
isOwner,
onRemove,
onPlay,
onMove
}: {
row: PlaylistTrack;
isOwner: boolean;
onRemove: (position: number) => void;
onPlay: (position: number) => void;
onMove?: (fromPos: number, toPos: number) => void;
} = $props();
// Two different ways a row can be dead, with the same behaviour and
// deliberately different copy. A removed track is gone for good; a
// missing file is a track we still have — history, likes and all —
// whose bytes aren't on disk right now (#2527). Telling the user
// "file missing" invites them to fix it; a strikethrough would say
// "deleted", which is a lie about a file that may well come back.
const isMissingFile = $derived(row.unavailable);
const isRemoved = $derived(row.track_id === null);
const isUnavailable = $derived(isRemoved || isMissingFile);
// null when the entry cannot be played, which hides like, radio, add
// and the menu.
const liveTrack = $derived(playlistTrackToRef(row));
const canDrag = $derived(isOwner && !isUnavailable);
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') {
e.preventDefault();
onMove?.(row.position, row.position - 1);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
onMove?.(row.position, row.position + 1);
} else if (e.key === ' ' || e.key === 'Enter') {
// Reorder activates via arrow keys, not Space/Enter. Prevent
// default so Space doesn't scroll the list and Enter doesn't
// fire the button's no-op click.
e.preventDefault();
}
}
</script>
<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)}
>
{#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}
{#snippet note()}
{#if isMissingFile}
<span class="text-error-fg"> · File missing</span>
{/if}
{/snippet}
{#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>