Merge branch 'feature/multiselect-completion-381' into pre-release

This commit is contained in:
vicrodh committed 2026-05-13 10:48:52 -06:00
commit 427e1b5bec
3 files changed
+235 -3

No files matched your search

@@ -18,6 +18,7 @@
import TrackRow from './TrackRow.svelte';
import ImageLightbox from './ImageLightbox.svelte';
import { formatTrackTitle } from '$lib/utils/trackTitle';
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
interface LocalTrack {
id: number;
@@ -118,6 +119,20 @@
getFullArtworkUrl: (path?: string) => string;
buildAlbumSections: (tracks: LocalTrack[]) => AlbumSection[];
normalizeArtistName: (name: string) => string;
/**
* One of the parent's selection contexts (tracks tab, albums tab,
* folder tree) is currently active. When this flips to `true` while
* this view has its own local selection mode on, the local
* selection is cleared — light-switch mutex across the parent/child
* boundary so two selections can never be live at the same time.
*/
parentSelectionActive?: boolean;
/**
* Called when the user toggles ON this view's local selection mode.
* Parent uses it to clear its own selection contexts so the
* footer's BulkActionBar reflects a single source of truth.
*/
onSelectionEntered?: () => void;
}
let {
@@ -147,6 +162,8 @@
getFullArtworkUrl,
buildAlbumSections,
normalizeArtistName,
parentSelectionActive = false,
onSelectionEntered,
}: Props = $props();
let trackSearchQuery = $state('');
@@ -191,20 +208,102 @@
function toggleSelectionMode() {
selectionMode = !selectionMode;
if (!selectionMode) selectedTrackIds = new Set();
if (!selectionMode) {
selectedTrackIds = new Set();
selectionAnchorId = null;
} else {
// Notify parent so it can clear its own selection contexts —
// light-switch mutex with the parent (tracks-tab / albums-tab /
// folder-tree). The parent's `clearOtherSelectionContexts`
// handles the cross-context teardown.
onSelectionEntered?.();
}
}
function toggleTrackSelection(trackId: number) {
// Inbound half of the cross-component mutex: when the parent enters
// any of its own selection contexts, our local selection turns off.
// No infinite loop because the toggle above only fires
// `onSelectionEntered` on the ENTRY transition (false -> true).
$effect(() => {
if (parentSelectionActive && selectionMode) {
selectionMode = false;
selectedTrackIds = new Set();
selectionAnchorId = null;
}
});
// Anchor for shift-click range selection. Set by every plain
// (non-shift) click and consumed by the next shift-click. Reset
// whenever selection mode is exited so a future re-entry starts
// from a fresh anchor.
let selectionAnchorId: number | null = null;
/**
* Toggle a single track in the selection, with optional shift-range
* extension. When `shift` is true and an anchor exists, every track
* between the anchor and `trackId` (inclusive, in current visual
* order) is added to the selection — matches the tracks-tab behaviour
* in `LocalLibraryView`. Without shift, this is a plain toggle and
* the clicked track becomes the new anchor.
*/
function toggleTrackSelection(trackId: number, shift: boolean = false) {
if (shift && selectionAnchorId !== null && selectionAnchorId !== trackId) {
const ids = tracks.map((trk) => trk.id);
const lastIndex = ids.indexOf(selectionAnchorId);
const currentIndex = ids.indexOf(trackId);
if (lastIndex !== -1 && currentIndex !== -1) {
selectedTrackIds = applyShiftRange({
current: selectedTrackIds,
ids,
lastIndex,
currentIndex,
});
return;
}
}
const next = new Set(selectedTrackIds);
if (next.has(trackId)) next.delete(trackId);
else next.add(trackId);
selectedTrackIds = next;
selectionAnchorId = trackId;
}
function clearSelection() {
selectedTrackIds = new Set();
selectionAnchorId = null;
}
// Tri-state for the "Select All" checkbox at the top of the track
// list — 'none' / 'partial' / 'all' based on how many of the
// currently visible album tracks are in the selection set.
const selectAllState = $derived(
tracks.length === 0 ? 'none' as const
: selectedTrackIds.size === 0 ? 'none' as const
: tracks.every((trk) => selectedTrackIds.has(trk.id)) ? 'all' as const
: 'partial' as const
);
function toggleSelectAll() {
if (selectAllState === 'all') {
selectedTrackIds = new Set();
} else {
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
}
}
// Ctrl/Cmd+A while selection mode is active selects every visible
// track. No-op when focus is in a text input.
$effect(() => {
if (!selectionMode) return;
const handler = (e: KeyboardEvent) => {
if (!isSelectAllShortcut(e)) return;
e.preventDefault();
selectedTrackIds = new Set(tracks.map((trk) => trk.id));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
});
/** Resolve the selected IDs back to the full LocalTrack objects so we
* can split local vs Plex rows for the playlist add path. */
function selectedTracks(): LocalTrack[] {
@@ -381,6 +480,17 @@
>
<SquareCheckBig size={18} />
</button>
{#if selectionMode}
<label class="folder-album-select-all" title={$t('actions.selectAll')}>
<input
type="checkbox"
checked={selectAllState === 'all'}
indeterminate={selectAllState === 'partial'}
onchange={toggleSelectAll}
/>
<span>{$t('actions.selectAll')}</span>
</label>
{/if}
<button
type="button"
class="action-btn-circle"
@@ -504,7 +614,7 @@
hideFavorite={true}
selectable={selectionMode}
selected={selectedTrackIds.has(track.id)}
onToggleSelect={() => toggleTrackSelection(track.id)}
onToggleSelect={(e) => toggleTrackSelection(track.id, e.shiftKey)}
onArtistClick={track.artist && track.artist !== album.artist && onArtistClick
? () => onArtistClick?.(track.artist)
: undefined}
@@ -912,4 +1022,26 @@
opacity: 0.5;
cursor: not-allowed;
}
/* Select-all checkbox shown next to the selection-mode toggle in the
folder album header. Matches the visual rhythm of the other inline
toolbar items — small, sits beside the round action buttons rather
than competing with them. */
.folder-album-select-all {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.folder-album-select-all input[type='checkbox'] {
width: 14px;
height: 14px;
accent-color: var(--accent-primary);
cursor: pointer;
}
</style>
@@ -532,6 +532,25 @@
return new Map(filteredTracks.map((track, index) => [track.id, index]));
});
// Tri-state for the "Select All" checkbox shown next to the selection-
// mode toggle. 'all' / 'partial' / 'none' over the currently visible
// (filtered) tracks — same semantics as LocalLibraryView's track tab.
// Declared after `filteredTracks` because it reads from it.
const trackSelectAllState = $derived(
filteredTracks.length === 0 ? 'none' as const
: selectedTrackIds.size === 0 ? 'none' as const
: filteredTracks.every((trk) => selectedTrackIds.has(trk.id)) ? 'all' as const
: 'partial' as const
);
function toggleTrackSelectAll() {
if (trackSelectAllState === 'all') {
selectedTrackIds = new Set();
} else {
selectedTrackIds = new Set(filteredTracks.map((trk) => trk.id));
}
}
// Genre filter version to trigger reactivity
let genreFilterVersion = $state(0);
@@ -1579,6 +1598,17 @@
>
<SquareCheckBig size={16} />
</button>
{#if trackSelectMode}
<label class="select-all-checkbox" title={$t('actions.selectAll')}>
<input
type="checkbox"
checked={trackSelectAllState === 'all'}
indeterminate={trackSelectAllState === 'partial'}
onchange={toggleTrackSelectAll}
/>
<span>{$t('actions.selectAll')}</span>
</label>
{/if}
<GenreFilterButton context={GENRE_CONTEXT_TRACKS} variant="control" align="right" onFilterChange={handleGenreFilterChange} />
<div class="dropdown-container">
<button class="control-btn" onclick={() => (showTrackGroupMenu = !showTrackGroupMenu)}>
@@ -2276,6 +2306,27 @@
/>
<style>
/* Select-all checkbox shown next to the selection-mode toggle in the
Tracks tab toolbar. Visual rhythm matches the LocalLibraryView
equivalent so the Qobuz favorites and local-library tracks views
read the same way side-by-side. */
.select-all-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
}
.select-all-checkbox input[type='checkbox'] {
width: 15px;
height: 15px;
accent-color: var(--accent-primary);
cursor: pointer;
}
/* Exit animation for unfavorited tracks */
:global(.track-removing) {
animation: track-fade-out 300ms ease forwards;
@@ -1799,6 +1799,7 @@
function toggleTrackSelectMode() {
trackSelectMode = !trackSelectMode;
if (!trackSelectMode) selectedTrackIds = new Set();
else clearOtherSelectionContexts('tracks');
}
function toggleTrackSelect(id: number) {
@@ -1965,6 +1966,7 @@
function toggleAlbumSelectMode() {
albumSelectMode = !albumSelectMode;
if (!albumSelectMode) selectedAlbumIds = new Set();
else clearOtherSelectionContexts('albums');
}
function toggleAlbumSelect(album: LocalAlbum) {
@@ -2636,9 +2638,54 @@
if (!treeSelectMode) {
selectedTrackIds = new Set();
treeSelectedTracksById = new SvelteMap();
} else {
clearOtherSelectionContexts('tree');
}
}
/**
* Mutual exclusion between the three selection contexts in this view:
*
* - `tracks` — flat tracks-tab multi-select (trackSelectMode)
* - `albums` — albums-tab multi-select (albumSelectMode)
* - `tree` — folder-tree multi-select (treeSelectMode)
*
* The bulk-action bar in the footer is a single sink that doesn't know
* which context filled it, so allowing two contexts to be active at the
* same time confuses the user about what a bulk action will operate on.
* Whenever the user enters a new context, this function clears the
* other two — explicit, no `$effect` reactivity, no infinite loops.
*
* Pure light-switch behaviour: tap "select" in tracks while tree
* select is on, and tree just turns off — no warning toast, no
* disabled state, no extra UI noise. The user already understands
* how a toggle works.
*/
function clearOtherSelectionContexts(entering: 'tracks' | 'albums' | 'tree' | 'folder-album') {
// Turn off any parent mode flag that isn't the one being entered.
// The 'folder-album' value covers the child component's local
// selection mode (LocalLibraryFolderAlbumView). Its own state lives
// inside the child; the child reacts to a `parentSelectionActive`
// prop becoming true by clearing its local selection. Here we just
// ensure all parent contexts go off when the child enters select.
if (entering !== 'tracks' && trackSelectMode) trackSelectMode = false;
if (entering !== 'albums' && albumSelectMode) albumSelectMode = false;
if (entering !== 'tree' && treeSelectMode) treeSelectMode = false;
// `selectedTrackIds` is shared between tracks-tab and tree contexts,
// so it ALWAYS gets cleared on a context switch — the entering
// context starts fresh and the BulkActionBar count reflects only
// the new accumulation. The albums set is tab-local and only needs
// clearing when leaving albums.
if (selectedTrackIds.size > 0) selectedTrackIds = new Set();
if (entering !== 'albums' && selectedAlbumIds.size > 0) selectedAlbumIds = new Set();
if (entering !== 'tree') {
if (selectedFolders.size > 0) selectedFolders = new Set();
if (treeSelectedTracksById.size > 0) treeSelectedTracksById = new SvelteMap();
}
}
// Returns the count of selected track IDs whose file_path lives under
// the given folder. O(|selection|) — selection is small in practice.
function countSelectedUnder(folderPath: string): number {
@@ -6310,6 +6357,8 @@
{getFullArtworkUrl}
{buildAlbumSections}
{normalizeArtistName}
parentSelectionActive={trackSelectMode || albumSelectMode || treeSelectMode}
onSelectionEntered={() => clearOtherSelectionContexts('folder-album')}
/>
{:else if selectedFolderPath}
<LocalLibraryFolderDetail