From 8977e721c6413edc4d2253ebbe707f72e01f28e4 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 6 May 2026 22:21:51 -0600 Subject: [PATCH 01/11] refactor(album-detail): align tracklist columns and smooth select-mode toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reduce table-header left padding to 4px and switch the # column to text-align: left so track numbers line up with the bare quality badge in the toolbar above. Track rows in the album-detail context get the same 4px left padding via a :global override on .track-list and the track-number column switches to flex-start so the row stays aligned. - Match the select-all checkbox width to the track-row checkbox (24px) and shrink the checkbox itself to 15x15 so the header checkbox lands on the same x as the per-row ones. - Always render the select-all and track-row checkbox containers and animate width + opacity in/out (180ms ease) so toggling select mode no longer pops the column into existence. - Move the AlbumMenu (...) trigger to the end of the action row so the bulk-action triggers (CassetteTape, multi-select) sit before the overflow menu — same convention as other detail views. cc --- src/lib/components/TrackRow.svelte | 51 ++++++++----- .../components/views/AlbumDetailView.svelte | 76 ++++++++++++------- 2 files changed, 79 insertions(+), 48 deletions(-) diff --git a/src/lib/components/TrackRow.svelte b/src/lib/components/TrackRow.svelte index 4ca28c2c4..5e6fe0230 100644 --- a/src/lib/components/TrackRow.svelte +++ b/src/lib/components/TrackRow.svelte @@ -274,25 +274,26 @@ tabindex={isBlacklisted ? -1 : 0} onkeydown={(e) => e.key === 'Enter' && !isBlacklisted && (selectable ? onToggleSelect?.(e as unknown as MouseEvent) : onPlay?.())} > - - {#if selectable} - -
{ - e.stopPropagation(); - onToggleSelect?.(e); - }} - > - -
- {/if} + + +
{ + if (!selectable) return; + e.stopPropagation(); + onToggleSelect?.(e); + }} + > + +
@@ -523,11 +524,21 @@ } .track-checkbox { - width: 24px; + width: 0; display: flex; align-items: center; justify-content: center; flex-shrink: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: width 180ms ease, opacity 180ms ease; + } + + .track-checkbox.active { + width: 24px; + opacity: 1; + pointer-events: auto; } .track-checkbox input[type='checkbox'] { diff --git a/src/lib/components/views/AlbumDetailView.svelte b/src/lib/components/views/AlbumDetailView.svelte index 092864683..da771f1f0 100644 --- a/src/lib/components/views/AlbumDetailView.svelte +++ b/src/lib/components/views/AlbumDetailView.svelte @@ -750,18 +750,6 @@ {/if} - +
@@ -821,17 +821,16 @@
- {#if multiSelectMode} -
- -
- {/if} +
+ +
#
{$t('tracklist.title')}
{$t('tracklist.duration')}
@@ -1486,7 +1485,7 @@ .table-header { width: 100%; height: 40px; - padding: 0 16px; + padding: 0 16px 0 4px; display: flex; flex-direction: row; align-items: center; @@ -1498,23 +1497,44 @@ box-sizing: border-box; } + /* Match the track-row's left padding (overridden below) so the # column + and the quality badge above sit at the same x. */ + .track-list :global(.track-row) { + padding-left: 4px; + } + + .track-list :global(.track-row .track-number) { + justify-content: flex-start; + } + .col-select-all { - width: 32px; + width: 0; display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transition: width 180ms ease, opacity 180ms ease; + } + + .col-select-all.active { + width: 24px; + opacity: 1; + pointer-events: auto; } .col-select-all input[type="checkbox"] { - width: 16px; - height: 16px; + width: 15px; + height: 15px; accent-color: var(--accent-primary); cursor: pointer; } .col-number { width: 48px; - text-align: center; + text-align: left; } .col-title { From eed2f4f6360f14300ba1eafa629a436038f5ae24 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 6 May 2026 23:26:01 -0600 Subject: [PATCH 02/11] refactor(playlist-detail): unify selection on multiSelectMode and remove visual flash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Custom sort had two parallel selection systems: an inherent per-row checkbox column (selectedTrackKeys) used solely to enable Move Up/Down in a separate batch toolbar, and the toolbar's multiSelectMode for bulk actions. They had different stores, different widths, and one disabled the other. Consolidate onto multiSelectMode; drag and the existing per-row reorder controls cover ordering, and the wrapper propagates dragTrackIds for group moves. - Drop selectedTrackKeys, isSelectionMode, toggleTrackSelection, clearSelection, selectAllTracks, moveSelectedUp, moveSelectedDown and the associated batch-controls toolbar / col-checkbox header branch / inherent per-row checkbox. - Move the multi-select checkbox out of TrackRow into the wrapper so the column order in custom sort reads [checkbox][reorder][TrackRow]; TrackRow's internal track-checkbox is hidden via :global so its click + keyboard handlers stay wired through selectable=true. - Reserve 24px for the row-checkbox and the header col-select-all at all times and animate opacity (180ms) instead of width. Toggling select no longer reflows the columns to its right; the checkbox fades inside a slot that was always there. - Stable each-key for virtualized rows: use track identity (`--`) instead of `${visibleTrackRange.start + loopIdx}-${track.id}`. The old key mixed scroll state into the identity, so a re-render that bumped trackListScrollTop reused row instances against new data — rows showed mismatched track / # / album combinations until the next paint. cc --- .../views/PlaylistDetailView.svelte | 320 ++++++------------ 1 file changed, 95 insertions(+), 225 deletions(-) diff --git a/src/lib/components/views/PlaylistDetailView.svelte b/src/lib/components/views/PlaylistDetailView.svelte index d0cc8f3b1..8d73b5748 100644 --- a/src/lib/components/views/PlaylistDetailView.svelte +++ b/src/lib/components/views/PlaylistDetailView.svelte @@ -355,16 +355,19 @@ let customOrderMap = $state>(new Map()); // "trackId:isLocal" -> position let customOrderLoading = $state(false); let isCustomOrderMode = $derived(sortBy === 'custom'); + // Reorder while in Custom mode is handled by drag-and-drop on each row + // (single tracks) and by drag-and-drop on multi-selected tracks (group + // moves via dragTrackIds). The previous "selection mode" with its own + // checkbox column and floating up/down buttons was a parallel system + // that competed with the toolbar's multi-select; consolidating onto + // multiSelectMode keeps one selection model across the view. // Drag and drop state let draggedTrackIdx = $state(null); let dragOverIdx = $state(null); - // Batch selection state (for custom order mode) - let selectedTrackKeys = $state>(new Set()); // Set of "trackId:isLocal" keys - let isSelectionMode = $derived(isCustomOrderMode && selectedTrackKeys.size > 0); - - // Multi-select state (bulk actions, works in all sort modes) + // Multi-select state (bulk actions; works in every sort mode including + // Custom — drag-drop on selected rows reorders the group as a unit). let multiSelectMode = $state(false); let multiSelectedKeys = $state(new Set()); let lastSelectedIndex = $state(null); @@ -1320,29 +1323,6 @@ return `${trackId}:${isLocal}:${index}`; } - function toggleTrackSelection(track: DisplayTrack, index: number) { - const key = getTrackKey(track, index); - const newSet = new Set(selectedTrackKeys); - if (newSet.has(key)) { - newSet.delete(key); - } else { - newSet.add(key); - } - selectedTrackKeys = newSet; - } - - function clearSelection() { - selectedTrackKeys = new Set(); - } - - function selectAllTracks() { - const newSet = new Set(); - displayTracks.forEach((track, index) => { - newSet.add(getTrackKey(track, index)); - }); - selectedTrackKeys = newSet; - } - function toggleMultiSelectMode() { multiSelectMode = !multiSelectMode; if (!multiSelectMode) { @@ -1450,91 +1430,6 @@ } // Move all selected tracks up one position (as a group) - async function moveSelectedUp() { - if (selectedTrackKeys.size === 0) return; - - // Get indices of selected tracks (sorted) - const selectedIndices: number[] = []; - displayTracks.forEach((track, idx) => { - if (selectedTrackKeys.has(getTrackKey(track, idx))) { - selectedIndices.push(idx); - } - }); - selectedIndices.sort((a, b) => a - b); - - // Can't move up if first selected is already at top - if (selectedIndices[0] === 0) return; - - // Build new order: swap each selected with the one above - const currentOrder = displayTracks.map(trk => ({ - id: trk.isLocal ? Math.abs(trk.id) : trk.id, - isLocal: trk.isLocal ?? false - })); - - // Move from top to bottom to avoid conflicts - for (const idx of selectedIndices) { - const newIdx = idx - 1; - [currentOrder[newIdx], currentOrder[idx]] = [currentOrder[idx], currentOrder[newIdx]]; - } - - // Save new order - const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]); - try { - await invoke('v2_playlist_set_custom_order', { playlistId, orders }); - // Update local map - const newMap = new Map(); - orders.forEach(([id, isLocal, pos]) => { - newMap.set(`${id}:${isLocal}`, pos); - }); - customOrderMap = newMap; - } catch (err) { - console.error('Failed to move selected tracks:', err); - } - } - - // Move all selected tracks down one position (as a group) - async function moveSelectedDown() { - if (selectedTrackKeys.size === 0) return; - - // Get indices of selected tracks (sorted descending for moving down) - const selectedIndices: number[] = []; - displayTracks.forEach((track, idx) => { - if (selectedTrackKeys.has(getTrackKey(track, idx))) { - selectedIndices.push(idx); - } - }); - selectedIndices.sort((a, b) => b - a); // Descending - - // Can't move down if last selected is already at bottom - if (selectedIndices[0] === displayTracks.length - 1) return; - - // Build new order: swap each selected with the one below - const currentOrder = displayTracks.map(trk => ({ - id: trk.isLocal ? Math.abs(trk.id) : trk.id, - isLocal: trk.isLocal ?? false - })); - - // Move from bottom to top to avoid conflicts - for (const idx of selectedIndices) { - const newIdx = idx + 1; - [currentOrder[idx], currentOrder[newIdx]] = [currentOrder[newIdx], currentOrder[idx]]; - } - - // Save new order - const orders: [number, boolean, number][] = currentOrder.map((item, pos) => [item.id, item.isLocal, pos]); - try { - await invoke('v2_playlist_set_custom_order', { playlistId, orders }); - // Update local map - const newMap = new Map(); - orders.forEach(([id, isLocal, pos]) => { - newMap.set(`${id}:${isLocal}`, pos); - }); - customOrderMap = newMap; - } catch (err) { - console.error('Failed to move selected tracks:', err); - } - } - async function selectCustomArtwork() { try { const selected = await open({ @@ -2668,43 +2563,17 @@
- {#if isCustomOrderMode} -
-
- {#if selectedTrackKeys.size > 0} - {selectedTrackKeys.size} selected - - {:else} - - {/if} -
- {#if selectedTrackKeys.size > 0} -
- - -
- {/if} -
- {/if} -
- {#if multiSelectMode} -
- -
- {:else if isCustomOrderMode} -
- {/if} +
+ +
#
{$t('tracklist.title')}
@@ -2718,7 +2587,16 @@
- {#each visibleDisplayTracks as track, loopIdx (`${visibleTrackRange.start + loopIdx}-${track.id}`)} + + {#each visibleDisplayTracks as track, loopIdx (`${track.isLocal ? 'l' : 'q'}-${track.id}-${track.number ?? loopIdx}`)} {@const idx = visibleTrackRange.start + loopIdx} {@const downloadInfo = track.isLocal ? { status: 'none' as const, progress: 0 } : (getTrackOfflineCacheStatus?.(track.id) ?? { status: 'none' as const, progress: 0 })} {@const isActiveTrack = ( @@ -2749,17 +2627,28 @@ ondragend={handleDragEnd} ondrop={(e) => handleDrop(e, idx)} > + + +
{ + if (!multiSelectMode) return; + e.stopPropagation(); + toggleMultiSelect(track, idx, e); + }} + > + +
{#if isCustomOrderMode} - {@const trackKey = getTrackKey(track, idx)} - -