Merge branch 'feature/multiselect-completion-381' into pre-release
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user