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 TrackRow from './TrackRow.svelte';
|
||||||
import ImageLightbox from './ImageLightbox.svelte';
|
import ImageLightbox from './ImageLightbox.svelte';
|
||||||
import { formatTrackTitle } from '$lib/utils/trackTitle';
|
import { formatTrackTitle } from '$lib/utils/trackTitle';
|
||||||
|
import { applyShiftRange, isSelectAllShortcut } from '$lib/utils/multiSelect';
|
||||||
|
|
||||||
interface LocalTrack {
|
interface LocalTrack {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -118,6 +119,20 @@
|
|||||||
getFullArtworkUrl: (path?: string) => string;
|
getFullArtworkUrl: (path?: string) => string;
|
||||||
buildAlbumSections: (tracks: LocalTrack[]) => AlbumSection[];
|
buildAlbumSections: (tracks: LocalTrack[]) => AlbumSection[];
|
||||||
normalizeArtistName: (name: string) => string;
|
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 {
|
let {
|
||||||
@@ -147,6 +162,8 @@
|
|||||||
getFullArtworkUrl,
|
getFullArtworkUrl,
|
||||||
buildAlbumSections,
|
buildAlbumSections,
|
||||||
normalizeArtistName,
|
normalizeArtistName,
|
||||||
|
parentSelectionActive = false,
|
||||||
|
onSelectionEntered,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let trackSearchQuery = $state('');
|
let trackSearchQuery = $state('');
|
||||||
@@ -191,20 +208,102 @@
|
|||||||
|
|
||||||
function toggleSelectionMode() {
|
function toggleSelectionMode() {
|
||||||
selectionMode = !selectionMode;
|
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);
|
const next = new Set(selectedTrackIds);
|
||||||
if (next.has(trackId)) next.delete(trackId);
|
if (next.has(trackId)) next.delete(trackId);
|
||||||
else next.add(trackId);
|
else next.add(trackId);
|
||||||
selectedTrackIds = next;
|
selectedTrackIds = next;
|
||||||
|
selectionAnchorId = trackId;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selectedTrackIds = new Set();
|
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
|
/** Resolve the selected IDs back to the full LocalTrack objects so we
|
||||||
* can split local vs Plex rows for the playlist add path. */
|
* can split local vs Plex rows for the playlist add path. */
|
||||||
function selectedTracks(): LocalTrack[] {
|
function selectedTracks(): LocalTrack[] {
|
||||||
@@ -381,6 +480,17 @@
|
|||||||
>
|
>
|
||||||
<SquareCheckBig size={18} />
|
<SquareCheckBig size={18} />
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="action-btn-circle"
|
class="action-btn-circle"
|
||||||
@@ -504,7 +614,7 @@
|
|||||||
hideFavorite={true}
|
hideFavorite={true}
|
||||||
selectable={selectionMode}
|
selectable={selectionMode}
|
||||||
selected={selectedTrackIds.has(track.id)}
|
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 && track.artist !== album.artist && onArtistClick
|
||||||
? () => onArtistClick?.(track.artist)
|
? () => onArtistClick?.(track.artist)
|
||||||
: undefined}
|
: undefined}
|
||||||
@@ -912,4 +1022,26 @@
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
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>
|
</style>
|
||||||
@@ -532,6 +532,25 @@
|
|||||||
return new Map(filteredTracks.map((track, index) => [track.id, index]));
|
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
|
// Genre filter version to trigger reactivity
|
||||||
let genreFilterVersion = $state(0);
|
let genreFilterVersion = $state(0);
|
||||||
|
|
||||||
@@ -1579,6 +1598,17 @@
|
|||||||
>
|
>
|
||||||
<SquareCheckBig size={16} />
|
<SquareCheckBig size={16} />
|
||||||
</button>
|
</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} />
|
<GenreFilterButton context={GENRE_CONTEXT_TRACKS} variant="control" align="right" onFilterChange={handleGenreFilterChange} />
|
||||||
<div class="dropdown-container">
|
<div class="dropdown-container">
|
||||||
<button class="control-btn" onclick={() => (showTrackGroupMenu = !showTrackGroupMenu)}>
|
<button class="control-btn" onclick={() => (showTrackGroupMenu = !showTrackGroupMenu)}>
|
||||||
@@ -2276,6 +2306,27 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<style>
|
<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 */
|
/* Exit animation for unfavorited tracks */
|
||||||
:global(.track-removing) {
|
:global(.track-removing) {
|
||||||
animation: track-fade-out 300ms ease forwards;
|
animation: track-fade-out 300ms ease forwards;
|
||||||
|
|||||||
@@ -1799,6 +1799,7 @@
|
|||||||
function toggleTrackSelectMode() {
|
function toggleTrackSelectMode() {
|
||||||
trackSelectMode = !trackSelectMode;
|
trackSelectMode = !trackSelectMode;
|
||||||
if (!trackSelectMode) selectedTrackIds = new Set();
|
if (!trackSelectMode) selectedTrackIds = new Set();
|
||||||
|
else clearOtherSelectionContexts('tracks');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleTrackSelect(id: number) {
|
function toggleTrackSelect(id: number) {
|
||||||
@@ -1965,6 +1966,7 @@
|
|||||||
function toggleAlbumSelectMode() {
|
function toggleAlbumSelectMode() {
|
||||||
albumSelectMode = !albumSelectMode;
|
albumSelectMode = !albumSelectMode;
|
||||||
if (!albumSelectMode) selectedAlbumIds = new Set();
|
if (!albumSelectMode) selectedAlbumIds = new Set();
|
||||||
|
else clearOtherSelectionContexts('albums');
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleAlbumSelect(album: LocalAlbum) {
|
function toggleAlbumSelect(album: LocalAlbum) {
|
||||||
@@ -2636,9 +2638,54 @@
|
|||||||
if (!treeSelectMode) {
|
if (!treeSelectMode) {
|
||||||
selectedTrackIds = new Set();
|
selectedTrackIds = new Set();
|
||||||
treeSelectedTracksById = new SvelteMap();
|
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
|
// Returns the count of selected track IDs whose file_path lives under
|
||||||
// the given folder. O(|selection|) — selection is small in practice.
|
// the given folder. O(|selection|) — selection is small in practice.
|
||||||
function countSelectedUnder(folderPath: string): number {
|
function countSelectedUnder(folderPath: string): number {
|
||||||
@@ -6310,6 +6357,8 @@
|
|||||||
{getFullArtworkUrl}
|
{getFullArtworkUrl}
|
||||||
{buildAlbumSections}
|
{buildAlbumSections}
|
||||||
{normalizeArtistName}
|
{normalizeArtistName}
|
||||||
|
parentSelectionActive={trackSelectMode || albumSelectMode || treeSelectMode}
|
||||||
|
onSelectionEntered={() => clearOtherSelectionContexts('folder-album')}
|
||||||
/>
|
/>
|
||||||
{:else if selectedFolderPath}
|
{:else if selectedFolderPath}
|
||||||
<LocalLibraryFolderDetail
|
<LocalLibraryFolderDetail
|
||||||
|
|||||||
Reference in new issue
Block a user