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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user