From 600af0e527ed7a03970561deb576d2c438a44bdf Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 12 Apr 2026 12:29:39 +0200 Subject: [PATCH] feat: bulk multi-select + drag in PlaylistDetail, Favorites, and artist context menu (#157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PlaylistDetail: Ctrl/Cmd+Click enters select mode; bulk drag emits { type: 'songs', tracks } when ≥2 selected; filtered-view rows now also draggable as single songs - Favorites songs: full multi-select system — Ctrl+Click, Shift+Click, header toggle-all checkbox, bulk-selected highlight, bulk drag to queue, bulk-bar with Add to Playlist + Clear - ContextMenu: ArtistToPlaylistSubmenu resolves all artist album songs and forwards to AddToPlaylistSubmenu - Locale: common.clearSelection + playlists.addSelected in all 7 locales Co-Authored-By: Claude Sonnet 4.6 --- src/components/ContextMenu.tsx | 37 +++++++++- src/locales/de.ts | 2 + src/locales/en.ts | 2 + src/locales/es.ts | 2 + src/locales/fr.ts | 2 + src/locales/nb.ts | 2 + src/locales/nl.ts | 2 + src/locales/ru.ts | 2 + src/locales/zh.ts | 2 + src/pages/Favorites.tsx | 126 +++++++++++++++++++++++++++++++-- src/pages/PlaylistDetail.tsx | 106 +++++++++++++++++++++++---- src/styles/components.css | 14 +++- 12 files changed, 276 insertions(+), 23 deletions(-) diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 29afe305..989c1438 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -5,7 +5,7 @@ import StarRating from './StarRating'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; import { useShallow } from 'zustand/react/shallow'; -import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic'; +import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; import { useDownloadModalStore } from '../store/downloadModalStore'; @@ -175,6 +175,29 @@ function AlbumToPlaylistSubmenu({ albumId, onDone, triggerId }: { albumId: strin return ; } +// Resolves all songs from all of an artist's albums, then hands off to AddToPlaylistSubmenu. +function ArtistToPlaylistSubmenu({ artistId, onDone, triggerId }: { artistId: string; onDone: () => void; triggerId?: string }) { + const [resolvedIds, setResolvedIds] = useState(null); + + useEffect(() => { + (async () => { + const { albums } = await getArtist(artistId); + const albumSongs = await Promise.all(albums.map(a => getAlbum(a.id).then(r => r.songs))); + setResolvedIds(albumSongs.flat().map(s => s.id)); + })().catch(() => setResolvedIds([])); + }, [artistId]); + + if (resolvedIds === null) { + return ( +
+
+
+ ); + } + if (resolvedIds.length === 0) return null; + return ; +} + export default function ContextMenu() { const { t } = useTranslation(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore( @@ -800,6 +823,18 @@ export default function ContextMenu() {
handleAction(() => startRadio(artist.id, artist.name))}> {t('contextMenu.startRadio')}
+
{ setPlaylistSongIds([`artist:${artist.id}`]); setPlaylistSubmenuOpen(true); }} + onMouseLeave={() => setPlaylistSubmenuOpen(false)} + > + {t('contextMenu.addToPlaylist')} + + {playlistSubmenuOpen && playlistSongIds[0] === `artist:${artist.id}` && ( + { setPlaylistSubmenuOpen(false); closeContextMenu(); }} /> + )} +
handleAction(() => { const starred = isStarred(artist.id, artist.starred); diff --git a/src/locales/de.ts b/src/locales/de.ts index 92d20943..14156698 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -363,6 +363,7 @@ export const deTranslation = { filterNoGenres: 'Keine Genres gefunden', filterClear: 'Zurücksetzen', bulkSelected: '{{count}} ausgewählt', + clearSelection: 'Auswahl aufheben', bulkAddToPlaylist: 'Zur Playlist hinzufügen', bulkRemoveFromPlaylist: 'Aus Playlist entfernen', bulkClear: 'Auswahl aufheben', @@ -901,6 +902,7 @@ export const deTranslation = { titleBadge: 'Playlist', refreshSuggestions: 'Neue Vorschläge', addSong: 'Zur Playlist hinzufügen', + addSelected: 'Ausgewählte hinzufügen', cacheOffline: 'Playlist offline speichern', offlineCached: 'Playlist gecacht', removeOffline: 'Aus Offline-Cache entfernen', diff --git a/src/locales/en.ts b/src/locales/en.ts index 56948021..63a2f856 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -364,6 +364,7 @@ export const enTranslation = { filterNoGenres: 'No genres match', filterClear: 'Clear', bulkSelected: '{{count}} selected', + clearSelection: 'Clear selection', bulkAddToPlaylist: 'Add to Playlist', bulkRemoveFromPlaylist: 'Remove from Playlist', bulkClear: 'Clear selection', @@ -903,6 +904,7 @@ export const enTranslation = { titleBadge: 'Playlist', refreshSuggestions: 'New suggestions', addSong: 'Add to playlist', + addSelected: 'Add selected', cacheOffline: 'Cache playlist offline', offlineCached: 'Playlist cached', removeOffline: 'Remove from offline cache', diff --git a/src/locales/es.ts b/src/locales/es.ts index 304f9c90..7de99911 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -356,6 +356,7 @@ export const esTranslation = { filterNoGenres: 'Ningún género coincide', filterClear: 'Limpiar', bulkSelected: '{{count}} seleccionados', + clearSelection: 'Limpiar selección', bulkAddToPlaylist: 'Agregar a Lista', bulkRemoveFromPlaylist: 'Quitar de Lista', bulkClear: 'Limpiar selección', @@ -890,6 +891,7 @@ export const esTranslation = { titleBadge: 'Lista', refreshSuggestions: 'Nuevas sugerencias', addSong: 'Agregar a lista', + addSelected: 'Agregar seleccionados', cacheOffline: 'Guardar lista offline', offlineCached: 'Lista guardada', removeOffline: 'Quitar de caché offline', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index f01d3058..0b32a350 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -363,6 +363,7 @@ export const frTranslation = { filterNoGenres: 'Aucun genre trouvé', filterClear: 'Effacer', bulkSelected: '{{count}} sélectionné(s)', + clearSelection: 'Effacer la sélection', bulkAddToPlaylist: 'Ajouter à la playlist', bulkRemoveFromPlaylist: 'Retirer de la playlist', bulkClear: 'Désélectionner', @@ -899,6 +900,7 @@ export const frTranslation = { titleBadge: 'Playlist', refreshSuggestions: 'Nouvelles suggestions', addSong: 'Ajouter à la playlist', + addSelected: 'Ajouter la sélection', cacheOffline: 'Mettre la playlist hors ligne', offlineCached: 'Playlist en cache', removeOffline: 'Retirer du cache hors ligne', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index cbce3148..325eedf4 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -363,6 +363,7 @@ export const nbTranslation = { filterNoGenres: 'Ingen sjangre samsvarer', filterClear: 'Tøm', bulkSelected: '{{count}} valgt', + clearSelection: 'Fjern utvalg', bulkAddToPlaylist: 'Legg til i spilleliste', bulkRemoveFromPlaylist: 'Fjern fra spilleliste', bulkClear: 'Tøm utvalg', @@ -898,6 +899,7 @@ export const nbTranslation = { titleBadge: 'Spilleliste', refreshSuggestions: 'Nye forslag', addSong: 'Legg til i spilleliste', + addSelected: 'Legg til valgte', cacheOffline: 'Bufre spilleliste offline', offlineCached: 'Spilleliste bufret', removeOffline: 'Fjern fra offline-buffer', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 3bf5fb03..5d5bfe8c 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -363,6 +363,7 @@ export const nlTranslation = { filterNoGenres: 'Geen genres gevonden', filterClear: 'Wissen', bulkSelected: '{{count}} geselecteerd', + clearSelection: 'Selectie wissen', bulkAddToPlaylist: 'Toevoegen aan afspeellijst', bulkRemoveFromPlaylist: 'Verwijderen uit afspeellijst', bulkClear: 'Selectie wissen', @@ -899,6 +900,7 @@ export const nlTranslation = { titleBadge: 'Playlist', refreshSuggestions: 'Nieuwe suggesties', addSong: 'Toevoegen aan playlist', + addSelected: 'Geselecteerde toevoegen', cacheOffline: 'Playlist offline opslaan', offlineCached: 'Playlist gecached', removeOffline: 'Verwijder uit offline cache', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 0fc92e79..e9e26004 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -377,6 +377,7 @@ export const ruTranslation = { filterNoGenres: 'Нет совпадений', filterClear: 'Сбросить', bulkSelected: 'Выбрано: {{count}}', + clearSelection: 'Сбросить выбор', bulkAddToPlaylist: 'В плейлист', bulkRemoveFromPlaylist: 'Убрать из плейлиста', bulkClear: 'Снять выделение', @@ -958,6 +959,7 @@ export const ruTranslation = { titleBadge: 'Плейлист', refreshSuggestions: 'Обновить подборку', addSong: 'В плейлист', + addSelected: 'Добавить выбранные', cacheOffline: 'Сохранить плейлист офлайн', offlineCached: 'Плейлист сохранён', removeOffline: 'Удалить из офлайн-кэша', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 9db55e56..122385e4 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -359,6 +359,7 @@ export const zhTranslation = { filterNoGenres: '未找到匹配流派', filterClear: '清除', bulkSelected: '已选 {{count}} 首', + clearSelection: '清除选择', bulkAddToPlaylist: '添加到播放列表', bulkRemoveFromPlaylist: '从播放列表移除', bulkClear: '取消选择', @@ -895,6 +896,7 @@ export const zhTranslation = { titleBadge: '播放列表', refreshSuggestions: '新建议', addSong: '添加到播放列表', + addSelected: '添加所选', cacheOffline: '离线缓存播放列表', offlineCached: '播放列表已缓存', removeOffline: '从离线缓存中移除', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index b333592e..bf2de86d 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTracklistColumns, type ColDef } from '../utils/useTracklistColumns'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; @@ -16,6 +16,8 @@ import { useTranslation } from 'react-i18next'; import { unstar } from '../api/subsonic'; import { useDragDrop } from '../contexts/DragDropContext'; import { useAuthStore } from '../store/authStore'; +import { useSelectionStore } from '../store/selectionStore'; +import { AddToPlaylistSubmenu } from '../components/ContextMenu'; const FAV_COLUMNS: readonly ColDef[] = [ { key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true }, @@ -42,6 +44,12 @@ export default function Favorites() { } = useTracklistColumns(FAV_COLUMNS, 'psysonic_favorites_columns'); const [ratings, setRatings] = useState>({}); + const [showPlPicker, setShowPlPicker] = useState(false); + + const selectedCount = useSelectionStore(s => s.selectedIds.size); + const selectedIds = useSelectionStore(s => s.selectedIds); + const inSelectMode = selectedCount > 0; + const lastSelectedIdxRef = useRef(null); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); @@ -80,6 +88,44 @@ export default function Favorites() { const navigate = useNavigate(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + // Clear selection when song list changes + useEffect(() => { + useSelectionStore.getState().clearAll(); + lastSelectedIdxRef.current = null; + }, [songs]); + + // Clear selection on click outside tracklist + useEffect(() => { + if (!inSelectMode) return; + const handler = (e: MouseEvent) => { + if (tracklistRef.current && !tracklistRef.current.contains(e.target as Node)) { + useSelectionStore.getState().clearAll(); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, [inSelectMode]); + + const toggleSelect = useCallback((id: string, idx: number, shift: boolean) => { + useSelectionStore.getState().setSelectedIds(prev => { + const next = new Set(prev); + if (shift && lastSelectedIdxRef.current !== null) { + const from = Math.min(lastSelectedIdxRef.current, idx); + const to = Math.max(lastSelectedIdxRef.current, idx); + // we need visibleSongs here — read from latest closure via ref trick + // Instead, just toggle range based on idx into songs array + for (let j = from; j <= to; j++) { + const sid = songs[j]?.id; + if (sid) next.add(sid); + } + } else { + if (next.has(id)) { next.delete(id); } + else { next.add(id); lastSelectedIdxRef.current = idx; } + } + return next; + }); + }, [songs]); + useEffect(() => { const loadAll = async () => { const [starredResult] = await Promise.allSettled([ @@ -173,14 +219,68 @@ export default function Favorites() { {t('favorites.enqueueAll')}
-
+
{ + if (inSelectMode && e.target === e.currentTarget) useSelectionStore.getState().clearAll(); + }}> + + {/* ── Bulk action bar ── */} + {inSelectMode && ( +
+ + {t('common.bulkSelected', { count: selectedCount })} + +
+ + {showPlPicker && ( + { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} + dropDown + /> + )} +
+ +
+ )} +
{visibleCols.map((colDef, colIndex) => { const key = colDef.key; const isLastCol = colIndex === visibleCols.length - 1; const label = colDef.i18nKey ? t(`albumDetail.${colDef.i18nKey}`) : ''; - if (key === 'num') return
#
; + if (key === 'num') { + const allSelected = selectedCount === visibleSongs.length && visibleSongs.length > 0; + return ( +
+ { + e.stopPropagation(); + if (allSelected) { + useSelectionStore.getState().clearAll(); + } else { + useSelectionStore.getState().setSelectedIds(() => new Set(visibleSongs.map(s => s.id))); + } + }} + /> + # +
+ ); + } if (key === 'title') { const hasNextCol = colIndex + 1 < visibleCols.length; return ( @@ -236,14 +336,21 @@ export default function Favorites() {
{visibleSongs.map((song, i) => { const track = songToTrack(song); + const isSelected = selectedIds.has(song.id); return (
{ if ((e.target as HTMLElement).closest('button, a, input')) return; - playTrack(track, visibleSongs.map(songToTrack)); + if (e.ctrlKey || e.metaKey) { + toggleSelect(song.id, i, false); + } else if (inSelectMode) { + toggleSelect(song.id, i, e.shiftKey); + } else { + playTrack(track, visibleSongs.map(songToTrack)); + } }} onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }} role="row" @@ -255,7 +362,13 @@ export default function Favorites() { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); + const { selectedIds: selIds } = useSelectionStore.getState(); + if (selIds.has(song.id) && selIds.size > 1) { + const bulkTracks = visibleSongs.filter(s => selIds.has(s.id)).map(songToTrack); + psyDrag.startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); + } else { + psyDrag.startDrag({ data: JSON.stringify({ type: 'song', track }), label: song.title }, me.clientX, me.clientY); + } } }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; @@ -267,6 +380,7 @@ export default function Favorites() { switch (colDef.key) { case 'num': return (
{ e.stopPropagation(); playTrack(track, visibleSongs.map(songToTrack)); }}> + { e.stopPropagation(); toggleSelect(song.id, i, e.shiftKey); }} /> {currentTrack?.id === song.id && isPlaying &&
} {i + 1} diff --git a/src/pages/PlaylistDetail.tsx b/src/pages/PlaylistDetail.tsx index 122c8a3e..50203656 100644 --- a/src/pages/PlaylistDetail.tsx +++ b/src/pages/PlaylistDetail.tsx @@ -211,6 +211,8 @@ export default function PlaylistDetail() { const [searchResults, setSearchResults] = useState([]); const [searching, setSearching] = useState(false); const searchDebounce = useRef | null>(null); + const [selectedSearchIds, setSelectedSearchIds] = useState>(new Set()); + const [searchPlPickerOpen, setSearchPlPickerOpen] = useState(false); // Suggestions const [suggestions, setSuggestions] = useState([]); @@ -439,7 +441,6 @@ export default function PlaylistDetail() { // ── Row mousedown: threshold drag for reorder (from anywhere on the row) ── const handleRowMouseDown = (e: React.MouseEvent, idx: number) => { if (e.button !== 0) return; - if (isFiltered) return; if ((e.target as HTMLElement).closest('button, input')) return; e.preventDefault(); const sx = e.clientX, sy = e.clientY; @@ -447,10 +448,21 @@ export default function PlaylistDetail() { if (Math.abs(me.clientX - sx) > 5 || Math.abs(me.clientY - sy) > 5) { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); - startDrag( - { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, - me.clientX, me.clientY - ); + if (!isFiltered && selectedIds.has(songs[idx]?.id) && selectedIds.size > 1) { + const bulkTracks = songs.filter(s => selectedIds.has(s.id)).map(songToTrack); + startDrag({ data: JSON.stringify({ type: 'songs', tracks: bulkTracks }), label: `${bulkTracks.length} Songs` }, me.clientX, me.clientY); + } else if (!isFiltered) { + startDrag( + { data: JSON.stringify({ type: 'playlist_reorder', index: idx }), label: songs[idx]?.title ?? '' }, + me.clientX, me.clientY + ); + } else { + // filtered view: single-song drag to queue + startDrag( + { data: JSON.stringify({ type: 'song', track: songToTrack(songs[idx]) }), label: songs[idx]?.title ?? '' }, + me.clientX, me.clientY + ); + } } }; const onUp = () => { @@ -624,10 +636,11 @@ export default function PlaylistDetail() {
+ {/* search close resets selection */} {songs.length > 0 && id && (
)} - {searchResults.map(song => ( -
addSong(song)}> - -
- {song.title} - {song.artist} · {song.album} + {selectedSearchIds.size > 0 && ( +
+ + {t('common.bulkSelected', { count: selectedSearchIds.size })} + + +
+ + {searchPlPickerOpen && ( + { setSearchPlPickerOpen(false); setSelectedSearchIds(new Set()); }} + /> + )}
- {formatDuration(song.duration ?? 0)} +
- ))} + )} + {searchResults.map(song => { + const isSelected = selectedSearchIds.has(song.id); + return ( +
addSong(song)} + > + e.stopPropagation()} + onChange={() => setSelectedSearchIds(prev => { + const next = new Set(prev); + next.has(song.id) ? next.delete(song.id) : next.add(song.id); + return next; + })} + /> + +
+ {song.title} + {song.artist} · {song.album} +
+ {formatDuration(song.duration ?? 0)} +
+ ); + })}
)} @@ -933,7 +1007,9 @@ export default function PlaylistDetail() { onMouseDown={e => handleRowMouseDown(e, realIdx)} onClick={e => { if ((e.target as HTMLElement).closest('button, a, input')) return; - if (selectedIds.size > 0) { + if (e.ctrlKey || e.metaKey) { + toggleSelect(song.id, i, false); + } else if (selectedIds.size > 0) { toggleSelect(song.id, i, e.shiftKey); } else { playTrack(displayedTracks[i], displayedTracks); diff --git a/src/styles/components.css b/src/styles/components.css index c4d20fcb..87f5f269 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -6187,7 +6187,7 @@ html.no-compositing .fs-lyrics-rail { .playlist-search-row { display: grid; - grid-template-columns: 36px 1fr auto 52px 28px; + grid-template-columns: 18px 36px 1fr auto 52px 28px; align-items: center; gap: var(--space-2); padding: 3px var(--space-1); @@ -6199,6 +6199,18 @@ html.no-compositing .fs-lyrics-rail { background: var(--bg-hover); } +.playlist-search-row--selected { + background: color-mix(in srgb, var(--accent) 10%, transparent); +} + +.playlist-search-checkbox { + width: 14px; + height: 14px; + accent-color: var(--accent); + cursor: pointer; + flex-shrink: 0; +} + .playlist-search-thumb { width: 36px; height: 36px;