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 () => {