feat(playlist): implement custom order UI with move controls

- Add custom order state management in PlaylistDetailView
- Load custom order when entering custom sort mode
- Initialize custom order from current tracks if not exists
- Add move up/down buttons for track reordering
- Update sorting logic to use customOrderMap
- Add CSS styles for reorder controls

cc
This commit is contained in:
vicrodh committed 2026-01-26 21:28:24 -06:00
1 parent 426584aa59
commit 53b8605c3b
1 file changed
+225 -3
@@ -1,5 +1,5 @@
<script lang="ts">
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus } from 'lucide-svelte';
import { ArrowLeft, Play, Shuffle, ListMusic, Search, X, ChevronDown, ChevronRight, ChevronUp, ImagePlus, Edit3, BarChart2, Heart, CloudDownload, ListPlus, GripVertical } from 'lucide-svelte';
import AlbumMenu from '../AlbumMenu.svelte';
import PlaylistCollage from '../PlaylistCollage.svelte';
import PlaylistModal from '../PlaylistModal.svelte';
@@ -211,6 +211,11 @@
let editModalOpen = $state(false);
let isFavorite = $state(false);
// Custom order state
let customOrderMap = $state<Map<string, number>>(new Map()); // "trackId:isLocal" -> position
let customOrderLoading = $state(false);
let isCustomOrderMode = $derived(sortBy === 'custom');
// User ownership state (to show "Copy to Library" button for non-owned playlists)
let currentUserId = $state<number | null>(null);
let isOwnPlaylist = $derived(playlist !== null && currentUserId !== null && playlist.owner.id === currentUserId);
@@ -486,6 +491,7 @@
searchQuery = '';
playlistSettings = null;
isFavorite = false;
customOrderMap = new Map();
// Skip loading settings for pending playlists
if (playlistId < 0) {
@@ -501,6 +507,11 @@
customArtworkPath = settings.custom_artwork_path || null;
searchQuery = settings.last_search_query || '';
isFavorite = settings.is_favorite ?? false;
// Load custom order if in custom mode
if (sortBy === 'custom') {
await loadOrInitCustomOrder();
}
}
} catch (err) {
console.error('Failed to load playlist settings:', err);
@@ -555,6 +566,12 @@
sortBy = field;
sortOrder = order;
showSortMenu = false;
// When switching to custom mode, load or initialize custom order
if (field === 'custom') {
await loadOrInitCustomOrder();
}
try {
await invoke('playlist_set_sort', { playlistId, sortBy: field, sortOrder: order });
} catch (err) {
@@ -562,6 +579,131 @@
}
}
// Load custom order from backend or initialize if not exists
async function loadOrInitCustomOrder() {
if (playlistId < 0) return; // Skip for pending playlists
customOrderLoading = true;
try {
// Check if custom order exists
const hasOrder = await invoke<boolean>('playlist_has_custom_order', { playlistId });
if (hasOrder) {
// Load existing custom order
const orders = await invoke<[number, boolean, number][]>('playlist_get_custom_order', { playlistId });
const newMap = new Map<string, number>();
for (const [trackId, isLocal, position] of orders) {
newMap.set(`${trackId}:${isLocal}`, position);
}
customOrderMap = newMap;
} else {
// Initialize from current track arrangement
await initCustomOrderFromCurrentTracks();
}
} catch (err) {
console.error('Failed to load custom order:', err);
} finally {
customOrderLoading = false;
}
}
// Initialize custom order from the current track list
async function initCustomOrderFromCurrentTracks() {
// Get all tracks in current display order (before custom sort applied)
const allTracks = [...tracks];
const localTracksInPlaylist = localTracks.map((t, idx) => ({
...t,
playlist_position: idx
}));
// Build track ID list: (trackId, isLocal)
const trackIds: [number, boolean][] = [];
// Add Qobuz tracks first (in original order)
for (const t of allTracks) {
if (!t.isLocal) {
trackIds.push([t.id, false]);
}
}
// Add local tracks (by their position)
for (const t of localTracksInPlaylist) {
trackIds.push([t.id, true]);
}
// Save to backend
try {
await invoke('playlist_init_custom_order', { playlistId, trackIds });
// Update local state
const newMap = new Map<string, number>();
for (let i = 0; i < trackIds.length; i++) {
const [trackId, isLocal] = trackIds[i];
newMap.set(`${trackId}:${isLocal}`, i);
}
customOrderMap = newMap;
} catch (err) {
console.error('Failed to initialize custom order:', err);
}
}
// Move a track to a new position
async function moveTrack(trackId: number, isLocal: boolean, fromIndex: number, toIndex: number) {
if (fromIndex === toIndex) return;
// Optimistic update: reorder the map
const key = `${trackId}:${isLocal}`;
const newMap = new Map(customOrderMap);
// Adjust positions
for (const [k, pos] of newMap) {
if (k === key) {
newMap.set(k, toIndex);
} else if (fromIndex < toIndex) {
// Moving down: shift tracks between from+1 and to up
if (pos > fromIndex && pos <= toIndex) {
newMap.set(k, pos - 1);
}
} else {
// Moving up: shift tracks between to and from-1 down
if (pos >= toIndex && pos < fromIndex) {
newMap.set(k, pos + 1);
}
}
}
customOrderMap = newMap;
// Persist to backend
try {
await invoke('playlist_move_track', {
playlistId,
trackId: Math.abs(trackId),
isLocal,
newPosition: toIndex
});
} catch (err) {
console.error('Failed to move track:', err);
// Reload to get consistent state
await loadOrInitCustomOrder();
}
}
// Helper to move track up one position
function moveTrackUp(track: DisplayTrack, currentIndex: number) {
if (currentIndex === 0) return;
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
moveTrack(trackId, isLocal, currentIndex, currentIndex - 1);
}
// Helper to move track down one position
function moveTrackDown(track: DisplayTrack, currentIndex: number) {
if (currentIndex >= displayTracks.length - 1) return;
const isLocal = track.isLocal ?? false;
const trackId = isLocal ? Math.abs(track.id) : track.id;
moveTrack(trackId, isLocal, currentIndex, currentIndex + 1);
}
async function selectCustomArtwork() {
try {
const selected = await open({
@@ -688,8 +830,14 @@
cmp = labelA.localeCompare(labelB);
break;
case 'custom':
// Use customPosition if available, otherwise preserve original order
cmp = (a.customPosition ?? a.addedIndex ?? 0) - (b.customPosition ?? b.addedIndex ?? 0);
// Get positions from customOrderMap
const aIsLocal = a.isLocal ?? false;
const bIsLocal = b.isLocal ?? false;
const aKey = `${aIsLocal ? Math.abs(a.id) : a.id}:${aIsLocal}`;
const bKey = `${bIsLocal ? Math.abs(b.id) : b.id}:${bIsLocal}`;
const aPos = customOrderMap.get(aKey) ?? a.addedIndex ?? 0;
const bPos = customOrderMap.get(bKey) ?? b.addedIndex ?? 0;
cmp = aPos - bPos;
break;
}
return sortOrder === 'desc' ? -cmp : cmp;
@@ -1214,8 +1362,32 @@
<div
class="track-row-wrapper"
class:unavailable={!available}
class:custom-order-mode={isCustomOrderMode}
title={!available ? $t('offline.trackNotAvailable') : undefined}
>
{#if isCustomOrderMode}
<div class="reorder-controls">
<button
class="reorder-btn"
onclick={() => moveTrackUp(track, idx)}
disabled={idx === 0}
title="Move up"
>
<ChevronUp size={16} />
</button>
<div class="drag-handle" title="Drag to reorder">
<GripVertical size={16} />
</div>
<button
class="reorder-btn"
onclick={() => moveTrackDown(track, idx)}
disabled={idx === displayTracks.length - 1}
title="Move down"
>
<ChevronDown size={16} />
</button>
</div>
{/if}
<TrackRow
trackId={track.isLocal ? undefined : track.id}
number={idx + 1}
@@ -1817,4 +1989,54 @@
filter: grayscale(100%);
}
/* Custom order mode */
.track-row-wrapper.custom-order-mode {
padding-left: 0;
}
.reorder-controls {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
margin-right: 8px;
flex-shrink: 0;
}
.reorder-btn {
background: transparent;
border: none;
color: var(--text-secondary, #888);
cursor: pointer;
padding: 2px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s, color 0.15s;
}
.reorder-btn:hover:not(:disabled) {
background: var(--hover-bg, rgba(255, 255, 255, 0.1));
color: var(--text-primary, #fff);
}
.reorder-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.drag-handle {
color: var(--text-secondary, #888);
cursor: grab;
padding: 2px;
display: flex;
align-items: center;
justify-content: center;
}
.drag-handle:active {
cursor: grabbing;
}
</style>