From 2e2c7549fc9e320b60e3f73a289e2fe52a31998a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Wed, 13 May 2026 10:48:42 -0600 Subject: [PATCH 1/2] feat(local-library): finalize #381 multiselect with cross-context apagador Completes the multiselect rollout in LocalLibraryView and the folder/album view: - Select-All checkbox in the folder-album track listing, mirroring the tracks-tab toolbar pattern. - Shift-click anchor and ctrl-a effect for track rows in the folder-album view. - Cross-context mutual exclusion ("apagador") between tree-select, album-select, track-select and the folder-album view's selection mode: entering any context clears the others' selections and turns off their selection mode, so two contexts cannot be active simultaneously. The mutex is plumbed via parentSelectionActive prop + onSelectionEntered callback between LocalLibraryView (orchestrator) and the LocalLibraryFolderAlbumView child, matching the simpler single-switch mental model the user requested. cc --- .../LocalLibraryFolderAlbumView.svelte | 138 +++++++++++++++++- .../components/views/LocalLibraryView.svelte | 49 +++++++ 2 files changed, 184 insertions(+), 3 deletions(-) diff --git a/src/lib/components/LocalLibraryFolderAlbumView.svelte b/src/lib/components/LocalLibraryFolderAlbumView.svelte index 407562de3..86b7dca5d 100644 --- a/src/lib/components/LocalLibraryFolderAlbumView.svelte +++ b/src/lib/components/LocalLibraryFolderAlbumView.svelte @@ -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 @@ > + {#if selectionMode} + + {/if} + {#if trackSelectMode} + + {/if}