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:
@@ -0,0 +1,151 @@
|
||||
<script lang="ts">
|
||||
// The one shape every track list in the web app draws (milestone 494):
|
||||
// a lead column (track number, drag grip or cover), title over a muted
|
||||
// subtitle, then like, radio, add-to-queue, the track menu and the
|
||||
// duration, then whatever the list adds at the end (remove, a timestamp).
|
||||
// The wrappers — TrackRow, PlaylistTrackRow, HistoryRow — own behaviour
|
||||
// (selection, reorder, remove); this component owns how a row looks, so
|
||||
// the lists cannot drift apart again.
|
||||
import type { Snippet } from 'svelte';
|
||||
import { Plus, Radio } from 'lucide-svelte';
|
||||
import type { TrackRef } from '#lib/api/types.js';
|
||||
import { formatDuration } from '#lib/media/duration.js';
|
||||
import { player, enqueueTrack, playRadio } from '#lib/player/store.svelte.js';
|
||||
import LikeButton from './LikeButton.svelte';
|
||||
import TrackMenu from './TrackMenu.svelte';
|
||||
import { reorderDrag, type ReorderDragOptions } from './reorder-drag';
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle = '',
|
||||
durationSec,
|
||||
track,
|
||||
ariaLabel,
|
||||
rowTitle,
|
||||
isSelected = false,
|
||||
disabled = false,
|
||||
struck = false,
|
||||
drag = null,
|
||||
onActivate,
|
||||
lead,
|
||||
note,
|
||||
trailing
|
||||
}: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
durationSec: number;
|
||||
/** The playable track. null when the row cannot be played, which hides
|
||||
like, radio, add and the menu: there is nothing for them to act on. */
|
||||
track: TrackRef | null;
|
||||
ariaLabel: string;
|
||||
/** Tooltip for the whole row. */
|
||||
rowTitle?: string;
|
||||
isSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Strike the title through: the track is gone for good. */
|
||||
struck?: boolean;
|
||||
drag?: ReorderDragOptions | null;
|
||||
onActivate: (e: MouseEvent | KeyboardEvent) => void;
|
||||
lead?: Snippet;
|
||||
/** Appended to the subtitle line, e.g. a "File missing" marker. */
|
||||
note?: Snippet;
|
||||
trailing?: Snippet;
|
||||
} = $props();
|
||||
|
||||
const isCurrent = $derived(!!track && player.current?.id === track.id);
|
||||
|
||||
// The stripe is only drawn on rows with no state of their own. Tailwind
|
||||
// emits `odd:` after the plain utilities, so a striped odd row would win
|
||||
// over the now-playing or selected background.
|
||||
const background = $derived(
|
||||
isCurrent
|
||||
? 'border-l-2 border-l-accent bg-surface-hover'
|
||||
: isSelected
|
||||
? 'bg-surface-hover'
|
||||
: 'odd:bg-surface/50 hover:bg-surface-hover'
|
||||
);
|
||||
|
||||
function onclick(e: MouseEvent) {
|
||||
if (!disabled) onActivate(e);
|
||||
}
|
||||
|
||||
function onkeydown(e: KeyboardEvent) {
|
||||
// Keys pressed on a control inside the row belong to that control.
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
e.preventDefault();
|
||||
if (!disabled) onActivate(e);
|
||||
}
|
||||
|
||||
function onRadio(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (track) playRadio(track.id);
|
||||
}
|
||||
|
||||
function onAdd(e: MouseEvent) {
|
||||
e.stopPropagation();
|
||||
if (track) enqueueTrack(track);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-label={ariaLabel}
|
||||
aria-disabled={disabled || undefined}
|
||||
title={rowTitle}
|
||||
data-selected={isSelected}
|
||||
{onclick}
|
||||
{onkeydown}
|
||||
use:reorderDrag={drag}
|
||||
class="group relative flex w-full items-center gap-3 px-3 py-2 text-left text-sm
|
||||
transition-colors focus-visible:outline-solid focus-visible:outline-2
|
||||
focus-visible:outline-accent [&.neodrag-dragging]:z-10
|
||||
[&.neodrag-dragging]:bg-surface-hover [&.neodrag-dragging]:shadow-lg
|
||||
{background}
|
||||
{disabled ? 'cursor-default text-text-muted' : 'cursor-pointer text-text-primary'}"
|
||||
>
|
||||
{#if lead}
|
||||
<span class="flex w-8 shrink-0 items-center justify-center">{@render lead()}</span>
|
||||
{/if}
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate {struck ? 'line-through' : ''}">{title}</span>
|
||||
{#if subtitle || note}
|
||||
<span class="block truncate text-xs text-text-secondary">
|
||||
{subtitle}{#if note}{@render note()}{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
{#if track}
|
||||
<LikeButton entityType="track" entityId={track.id} />
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Play radio from this track"
|
||||
title="Play radio"
|
||||
onclick={onRadio}
|
||||
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
|
||||
>
|
||||
<Radio size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Add ${track.title} to queue`}
|
||||
title="Add to queue"
|
||||
onclick={onAdd}
|
||||
class="hidden shrink-0 rounded p-1 text-text-secondary hover:text-text-primary sm:inline-flex"
|
||||
>
|
||||
<Plus size={16} strokeWidth={1} />
|
||||
</button>
|
||||
<TrackMenu {track} />
|
||||
{/if}
|
||||
|
||||
<span class="w-12 shrink-0 text-right text-xs tabular-nums text-text-secondary">
|
||||
{formatDuration(durationSec)}
|
||||
</span>
|
||||
|
||||
{#if trailing}
|
||||
{@render trailing()}
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user