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>
117 lines
3.8 KiB
Svelte
117 lines
3.8 KiB
Svelte
<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>
|