diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3f7431b4..5d26826c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -106,6 +106,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **Right-click** on an album row now opens the standard album context menu (Play / Add to queue / Play next / Add to playlist / Go to artist) instead of firing a hidden direct-play action; right-click on a Top Artists card opens the artist context menu.
* The **play count** moved from a small right-aligned column to a localized **pill right next to the album title** — `11 plays` (en), `11× gespielt` (de) — since the play count is the central datum on this page.
+### Multi-select — Shift+Click range selection on grid pages
+
+**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#484](https://github.com/Psychotoxical/psysonic/pull/484)**
+
+* In multi-select mode on **Albums**, **Random Albums**, **New Releases** and **Playlists**, holding **Shift** while clicking a second card now selects every item between the anchor (last clicked) and the click target — the standard OS-level pattern. Range expansion follows the user-visible order, so filters and sort affect what gets included.
+* Plain click still toggles a single item and moves the anchor to it; behaviour without Shift is unchanged.
+
## Fixed
### Hot cache, HTTP streaming replay, and queue source indicator
diff --git a/src/components/AlbumCard.tsx b/src/components/AlbumCard.tsx
index 865c36fe..e7d2e77d 100644
--- a/src/components/AlbumCard.tsx
+++ b/src/components/AlbumCard.tsx
@@ -15,7 +15,7 @@ interface AlbumCardProps {
album: SubsonicAlbum;
selected?: boolean;
selectionMode?: boolean;
- onToggleSelect?: (id: string) => void;
+ onToggleSelect?: (id: string, opts?: { shiftKey?: boolean }) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
disableArtwork?: boolean;
@@ -54,15 +54,15 @@ function AlbumCard({
const psyDrag = useDragDrop();
const isNewAlbum = isAlbumRecentlyAdded(album.created);
- const handleClick = () => {
- if (selectionMode) { onToggleSelect?.(album.id); return; }
+ const handleClick = (opts?: { shiftKey?: boolean }) => {
+ if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
navigate(`/album/${album.id}`);
};
return (
handleClick({ shiftKey: e.shiftKey })}
role="button"
tabIndex={0}
aria-label={`${album.name} von ${album.artist}`}
diff --git a/src/hooks/useRangeSelection.ts b/src/hooks/useRangeSelection.ts
new file mode 100644
index 00000000..756c337a
--- /dev/null
+++ b/src/hooks/useRangeSelection.ts
@@ -0,0 +1,61 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+/**
+ * Multi-select state with Shift+Click range support.
+ *
+ * Pass the *currently visible* list (already filtered + sorted in the order
+ * the user sees it) so range expansion follows what's on screen, not the
+ * raw upstream data.
+ *
+ * - Plain click on an item: toggles that item and sets it as the new range anchor.
+ * - Shift-click on a second item: adds every item between the anchor and the
+ * click target (inclusive) to the selection. The anchor moves to the
+ * shift-clicked item so subsequent shift-clicks extend from there.
+ *
+ * The anchor is a ref, not state — moving it does not trigger re-renders.
+ */
+export function useRangeSelection
(items: T[]) {
+ const [selectedIds, setSelectedIds] = useState>(new Set());
+ const itemsRef = useRef(items);
+ useEffect(() => {
+ itemsRef.current = items;
+ }, [items]);
+ const anchorRef = useRef(null);
+
+ const toggleSelect = useCallback((id: string, opts?: { shiftKey?: boolean }) => {
+ // Snapshot the anchor *before* the state updater runs. React strict mode
+ // invokes setState updater functions twice in dev to surface side effects,
+ // so any ref mutation inside the updater would taint the second invocation
+ // and the replay would miss the range branch.
+ const anchorAtCallTime = anchorRef.current;
+ setSelectedIds(prev => {
+ const next = new Set(prev);
+ const list = itemsRef.current;
+
+ if (opts?.shiftKey && anchorAtCallTime && anchorAtCallTime !== id) {
+ const startIdx = list.findIndex(x => x.id === anchorAtCallTime);
+ const endIdx = list.findIndex(x => x.id === id);
+ if (startIdx >= 0 && endIdx >= 0) {
+ const lo = Math.min(startIdx, endIdx);
+ const hi = Math.max(startIdx, endIdx);
+ for (let i = lo; i <= hi; i++) {
+ next.add(list[i].id);
+ }
+ return next;
+ }
+ }
+
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ anchorRef.current = id;
+ }, []);
+
+ const clearSelection = useCallback(() => {
+ setSelectedIds(new Set());
+ anchorRef.current = null;
+ }, []);
+
+ return { selectedIds, setSelectedIds, toggleSelect, clearSelection };
+}
diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx
index 7c671afd..d5109feb 100644
--- a/src/pages/Albums.tsx
+++ b/src/pages/Albums.tsx
@@ -20,6 +20,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import { APP_MAIN_SCROLL_VIEWPORT_ID } from '../constants/appScroll';
import { useElementClientHeightById } from '../hooks/useResizeClientHeight';
import { usePerfProbeFlags } from '../utils/perfFlags';
+import { useRangeSelection } from '../hooks/useRangeSelection';
const ALBUM_GRID_GAP_PX = 16; // matches --space-4
const ALBUM_GRID_MIN_CARD_PX = 140;
@@ -63,26 +64,9 @@ export default function Albums() {
const observerTarget = useRef(null);
// ── Multi-selection ──────────────────────────────────────────────────────
+ // selectedIds + toggleSelect come from useRangeSelection (declared after
+ // `visibleAlbums` so Shift-click range expansion follows the visible order).
const [selectionMode, setSelectionMode] = useState(false);
- const [selectedIds, setSelectedIds] = useState>(new Set());
-
- const toggleSelectionMode = () => {
- setSelectionMode(v => !v);
- setSelectedIds(new Set());
- };
-
- const toggleSelect = useCallback((id: string) => {
- setSelectedIds(prev => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id); else next.add(id);
- return next;
- });
- }, []);
-
- const clearSelection = () => {
- setSelectionMode(false);
- setSelectedIds(new Set());
- };
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const visibleAlbums = useMemo(() => {
@@ -95,6 +79,18 @@ export default function Albums() {
return out;
}, [albums, compFilter, starredOnly, starredOverrides]);
+ const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(visibleAlbums);
+
+ const toggleSelectionMode = () => {
+ setSelectionMode(v => !v);
+ resetSelection();
+ };
+
+ const clearSelection = () => {
+ setSelectionMode(false);
+ resetSelection();
+ };
+
const albumGridWrapRef = useRef(null);
const [albumGridCols, setAlbumGridCols] = useState(4);
diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx
index b7b836bf..1ebd8f79 100644
--- a/src/pages/NewReleases.tsx
+++ b/src/pages/NewReleases.tsx
@@ -12,6 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
+import { useRangeSelection } from '../hooks/useRangeSelection';
const PAGE_SIZE = 30;
@@ -43,13 +44,10 @@ export default function NewReleases() {
const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
- const [selectedIds, setSelectedIds] = useState>(new Set());
+ const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
- const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
- const toggleSelect = useCallback((id: string) => {
- setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
- }, []);
- const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
+ const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
+ const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx
index c500484e..a93b16b3 100644
--- a/src/pages/Playlists.tsx
+++ b/src/pages/Playlists.tsx
@@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
import { ndCreateSmartPlaylist, ndGetSmartPlaylist, ndListSmartPlaylists, ndUpdateSmartPlaylist } from '../api/navidromeSmart';
+import { useRangeSelection } from '../hooks/useRangeSelection';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -225,7 +226,7 @@ export default function Playlists() {
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
- const [selectedIds, setSelectedIds] = useState>(new Set());
+ const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(playlists);
const isNavidromeServer = Boolean(
activeServerId &&
(subsonicIdentityByServer[activeServerId]?.type ?? '').toLowerCase() === 'navidrome',
@@ -233,20 +234,12 @@ export default function Playlists() {
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
- setSelectedIds(new Set());
+ resetSelection();
};
- const toggleSelect = useCallback((id: string) => {
- setSelectedIds(prev => {
- const next = new Set(prev);
- if (next.has(id)) next.delete(id); else next.add(id);
- return next;
- });
- }, []);
-
const clearSelection = () => {
setSelectionMode(false);
- setSelectedIds(new Set());
+ resetSelection();
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
@@ -902,9 +895,9 @@ export default function Playlists() {
{
+ onClick={(e) => {
if (selectionMode) {
- toggleSelect(pl.id);
+ toggleSelect(pl.id, { shiftKey: e.shiftKey });
} else {
navigate(`/playlists/${pl.id}`);
}
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index 7ed0a6de..12fe1474 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -12,6 +12,7 @@ import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
+import { useRangeSelection } from '../hooks/useRangeSelection';
const ALBUM_COUNT = 30;
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
@@ -53,13 +54,10 @@ export default function RandomAlbums() {
const filtered = selectedGenres.length > 0;
const [selectionMode, setSelectionMode] = useState(false);
- const [selectedIds, setSelectedIds] = useState>(new Set());
+ const { selectedIds, toggleSelect, clearSelection: resetSelection } = useRangeSelection(albums);
- const toggleSelectionMode = () => { setSelectionMode(v => !v); setSelectedIds(new Set()); };
- const toggleSelect = useCallback((id: string) => {
- setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
- }, []);
- const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
+ const toggleSelectionMode = () => { setSelectionMode(v => !v); resetSelection(); };
+ const clearSelection = () => { setSelectionMode(false); resetSelection(); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const handleDownloadZips = async () => {