diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c7188ea..5dbbd4dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * The "new version available" popup no longer shows blurry, unfocused text on some Linux setups (the background blur could bleed onto the dialog). The version arrow now lines up with the heading, and the Skip / Remind me later buttons read clearly — Remind me later is the highlighted action when there's no in-app installer. +### Favorites — bulk add to playlist and play/enqueue selected + +**By [@cucadmuh](https://github.com/cucadmuh), reported by zunoz on the Psysonic Discord, PR [#1140](https://github.com/Psychotoxical/psysonic/pull/1140)** + +* Bulk **Add to playlist** no longer cleared the selection on `mousedown` before the click ran, so chosen tracks were not actually added. +* With rows selected, **Play all** / **Add all to queue** become **Play selected** / **Add selected to queue** and act on the checked tracks only. +* Bulk add now snapshots every checked row when the picker opens so all selected tracks land in the playlist, not just the last one. + ## [1.48.1] - 2026-06-15 diff --git a/src/components/contextMenu/AddToPlaylistSubmenu.tsx b/src/components/contextMenu/AddToPlaylistSubmenu.tsx index 52fd9165..7850899b 100644 --- a/src/components/contextMenu/AddToPlaylistSubmenu.tsx +++ b/src/components/contextMenu/AddToPlaylistSubmenu.tsx @@ -12,15 +12,19 @@ import { interface Props { songIds: string[]; + /** When set (bulk toolbar pickers), read IDs at action time — avoids stale props if selection changes after open. */ + resolveSongIds?: () => readonly string[]; onDone: () => void; dropDown?: boolean; triggerId?: string; } -export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: Props) { +export function AddToPlaylistSubmenu({ songIds, resolveSongIds, onDone, dropDown, triggerId }: Props) { const { t } = useTranslation(); const subRef = useRef(null); const newNameRef = useRef(null); + const songIdsRef = useRef(songIds); + songIdsRef.current = songIds; const [adding, setAdding] = useState(null); const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); @@ -62,24 +66,27 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P if (creating) newNameRef.current?.focus(); }, [creating]); + const idsForAction = () => [...(resolveSongIds?.() ?? songIdsRef.current)]; + const handleAdd = async (pl: SubsonicPlaylist) => { + const ids = idsForAction(); setAdding(pl.id); try { const { songs } = await getPlaylist(pl.id); const existingIds = new Set(songs.map((s) => s.id)); - const newIds = songIds.filter((id) => !existingIds.has(id)); + const newIds = ids.filter((id) => !existingIds.has(id)); if (newIds.length > 0) { await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...newIds]); showToast(t('playlists.addSuccess', { count: newIds.length, playlist: pl.name })); touchPlaylist(pl.id); } else { - const accepted = await confirmAddAllDuplicates(pl.name, songIds.length, t); + const accepted = await confirmAddAllDuplicates(pl.name, ids.length, t); if (accepted) { - await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...songIds]); - showToast(t('playlists.addedAsDuplicates', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + await updatePlaylist(pl.id, [...songs.map((s) => s.id), ...ids]); + showToast(t('playlists.addedAsDuplicates', { count: ids.length, playlist: pl.name }), 3000, 'info'); touchPlaylist(pl.id); } else { - showToast(t('playlists.addAllSkipped', { count: songIds.length, playlist: pl.name }), 3000, 'info'); + showToast(t('playlists.addAllSkipped', { count: ids.length, playlist: pl.name }), 3000, 'info'); } } } catch { @@ -90,11 +97,12 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P }; const handleCreate = async () => { + const ids = idsForAction(); const name = newName.trim() || t('playlists.unnamed'); try { - const pl = await createPlaylist(name, songIds); + const pl = await createPlaylist(name, ids); if (pl?.id) { - showToast(t('playlists.createAndAddSuccess', { count: songIds.length, playlist: pl.name || name })); + showToast(t('playlists.createAndAddSuccess', { count: ids.length, playlist: pl.name || name })); } } catch { showToast(t('playlists.createError'), 3000, 'error'); @@ -111,7 +119,13 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: P : { left: '100%', right: 'auto', top: flipUp ? 'auto' : -4, bottom: flipUp ? 0 : 'auto' }; return ( -
+
e.stopPropagation() : undefined} + > {!creating ? (
; showPlPicker: boolean; setShowPlPicker: React.Dispatch>; } @@ -38,10 +39,19 @@ export default function FavoritesSongsSectionHeader({ selectedGenres, setSelectedGenres, yearRange, setYearRange, showFilters, setShowFilters, setSortKey, setSortClickCount, playTrack, enqueue, starredOverrides, minYear, currentYear, - inSelectMode, selectedCount, showPlPicker, setShowPlPicker, + inSelectMode, selectedCount, selectedIds, showPlPicker, setShowPlPicker, }: Props) { const { t } = useTranslation(); + const targetSongs = useMemo(() => { + if (!inSelectMode) return visibleSongs; + return visibleSongs.filter(s => selectedIds.has(s.id)); + }, [inSelectMode, visibleSongs, selectedIds]); + + // Snapshot selection when the picker opens so add-to-playlist still sees every + // checked row if a document mousedown races ahead of the playlist click. + const pickerSongIdsRef = useRef([]); + return (
{/* Title Row with showing X of Y indicator */} @@ -57,30 +67,30 @@ export default function FavoritesSongsSectionHeader({
{/* Action Buttons */} -
+
{/* Filter Toggle Button */} @@ -111,21 +121,27 @@ export default function FavoritesSongsSectionHeader({ {/* Bulk action chips — inline at row end so a selection does not push the column header / rows downward (matches Album toolbar). */} {inSelectMode && ( -
+
{t('common.bulkSelected', { count: selectedCount })}
{showPlPicker && ( pickerSongIdsRef.current} onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }} dropDown /> diff --git a/src/hooks/useFavoritesSelection.ts b/src/hooks/useFavoritesSelection.ts index 039d2822..8464066e 100644 --- a/src/hooks/useFavoritesSelection.ts +++ b/src/hooks/useFavoritesSelection.ts @@ -7,7 +7,7 @@ export interface FavoritesSelectionResult { } export function useFavoritesSelection( - songs: SubsonicSong[], + visibleSongs: SubsonicSong[], inSelectMode: boolean, tracklistRef: React.RefObject, ): FavoritesSelectionResult { @@ -17,15 +17,18 @@ export function useFavoritesSelection( useEffect(() => { useSelectionStore.getState().clearAll(); lastSelectedIdxRef.current = null; - }, [songs]); + }, [visibleSongs]); // 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(); - } + const target = e.target as HTMLElement; + if (!tracklistRef.current || tracklistRef.current.contains(target)) return; + // Toolbar (play/enqueue, filters, bulk actions) sits outside the tracklist + // DOM but belongs to the selection — don't clear before its click runs. + if (target.closest('.favorites-songs-toolbar, .bulk-pl-picker-wrap, .context-submenu')) return; + useSelectionStore.getState().clearAll(); }; document.addEventListener('mousedown', handler); return () => document.removeEventListener('mousedown', handler); @@ -37,10 +40,8 @@ export function useFavoritesSelection( 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; + const sid = visibleSongs[j]?.id; if (sid) next.add(sid); } } else { @@ -49,7 +50,7 @@ export function useFavoritesSelection( } return next; }); - }, [songs]); + }, [visibleSongs]); return { toggleSelect }; } diff --git a/src/locales/de/favorites.ts b/src/locales/de/favorites.ts index eb191a5f..cc6db489 100644 --- a/src/locales/de/favorites.ts +++ b/src/locales/de/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Alben', songs: 'Songs', enqueueAll: 'Alle in die Warteschlange', + enqueueSelected: 'Auswahl in die Warteschlange', playAll: 'Alle abspielen', + playSelected: 'Auswahl abspielen', removeSong: 'Aus Favoriten entfernen', stations: 'Radiosender', showingFiltered: 'Zeige {{filtered}} von {{total}} ({{artist}})', diff --git a/src/locales/en/favorites.ts b/src/locales/en/favorites.ts index 84838272..cb943ecd 100644 --- a/src/locales/en/favorites.ts +++ b/src/locales/en/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Albums', songs: 'Songs', enqueueAll: 'Add all to queue', + enqueueSelected: 'Add selected to queue', playAll: 'Play all', + playSelected: 'Play selected', removeSong: 'Remove from favorites', stations: 'Radio Stations', showingFiltered: 'Showing {{filtered}} of {{total}} ({{artist}})', diff --git a/src/locales/es/favorites.ts b/src/locales/es/favorites.ts index d19a70a2..b546d5dc 100644 --- a/src/locales/es/favorites.ts +++ b/src/locales/es/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Álbumes', songs: 'Canciones', enqueueAll: 'Agregar todo a la cola', + enqueueSelected: 'Selección a la cola', playAll: 'Reproducir todo', + playSelected: 'Reproducir selección', removeSong: 'Quitar de favoritos', stations: 'Estaciones de Radio', showingFiltered: 'Mostrando {{filtered}} de {{total}} ({{artist}})', diff --git a/src/locales/fr/favorites.ts b/src/locales/fr/favorites.ts index ef8b4e49..a56f92e3 100644 --- a/src/locales/fr/favorites.ts +++ b/src/locales/fr/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Albums', songs: 'Morceaux', enqueueAll: 'Tout ajouter à la file', + enqueueSelected: 'Sélection dans la file', playAll: 'Tout lire', + playSelected: 'Lire la sélection', removeSong: 'Retirer des favoris', stations: 'Stations de radio', showingFiltered: 'Affichage de {{filtered}} sur {{total}} ({{artist}})', diff --git a/src/locales/ja/favorites.ts b/src/locales/ja/favorites.ts index aad81230..0406785c 100644 --- a/src/locales/ja/favorites.ts +++ b/src/locales/ja/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'アルバム', songs: '曲', enqueueAll: 'すべてキューに追加', + enqueueSelected: '選択をキューに追加', playAll: 'すべて再生', + playSelected: '選択を再生', removeSong: 'お気に入りから削除', stations: 'ラジオ局', showingFiltered: '{{total}} 件中 {{filtered}} 件を表示 ({{artist}})', diff --git a/src/locales/nb/favorites.ts b/src/locales/nb/favorites.ts index e22d32ff..68cc2f35 100644 --- a/src/locales/nb/favorites.ts +++ b/src/locales/nb/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Album', songs: 'Sanger', enqueueAll: 'Legg alle i kø', + enqueueSelected: 'Valgte i kø', playAll: 'Spill alle', + playSelected: 'Spill valgte', removeSong: 'Fjern fra favoritter', stations: 'Radiostasjoner', showingFiltered: 'Viser {{filtered}} av {{total}} ({{artist}})', diff --git a/src/locales/nl/favorites.ts b/src/locales/nl/favorites.ts index b22e7103..ae3f4ad0 100644 --- a/src/locales/nl/favorites.ts +++ b/src/locales/nl/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Albums', songs: 'Nummers', enqueueAll: 'Alles aan wachtrij toevoegen', + enqueueSelected: 'Selectie aan wachtrij', playAll: 'Alles afspelen', + playSelected: 'Selectie afspelen', removeSong: 'Verwijderen uit favorieten', stations: 'Radiostations', showingFiltered: 'Toont {{filtered}} van {{total}} ({{artist}})', diff --git a/src/locales/ro/favorites.ts b/src/locales/ro/favorites.ts index 15dcc0f4..fb892299 100644 --- a/src/locales/ro/favorites.ts +++ b/src/locales/ro/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Albume', songs: 'Piese', enqueueAll: 'Adaugă tot la coadă', + enqueueSelected: 'Selecția la coadă', playAll: 'Redă tot', + playSelected: 'Redă selecția', removeSong: 'Șterge de la favorite', stations: 'Stații Radio', showingFiltered: 'Se afișează {{filtered}} din {{total}} ({{artist}})', diff --git a/src/locales/ru/favorites.ts b/src/locales/ru/favorites.ts index 0575fb94..83958bc2 100644 --- a/src/locales/ru/favorites.ts +++ b/src/locales/ru/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: 'Альбомы', songs: 'Треки', enqueueAll: 'Всё в очередь', + enqueueSelected: 'Выбранное в очередь', playAll: 'Воспроизвести всё', + playSelected: 'Воспроизвести выбранное', removeSong: 'Убрать из избранного', stations: 'Радиостанции', showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})', diff --git a/src/locales/zh/favorites.ts b/src/locales/zh/favorites.ts index 781d4e01..e2eaa42d 100644 --- a/src/locales/zh/favorites.ts +++ b/src/locales/zh/favorites.ts @@ -5,7 +5,9 @@ export const favorites = { albums: '专辑', songs: '歌曲', enqueueAll: '全部加入队列', + enqueueSelected: '所选加入队列', playAll: '全部播放', + playSelected: '播放所选', removeSong: '从收藏中移除', stations: '广播电台', showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})', diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx index cb81f9e3..eea5057a 100644 --- a/src/pages/Favorites.tsx +++ b/src/pages/Favorites.tsx @@ -10,6 +10,7 @@ import FavoritesSongsTracklist from '../components/favorites/FavoritesSongsTrack import { useFavoritesData } from '../hooks/useFavoritesData'; import { useFavoritesSongFiltering } from '../hooks/useFavoritesSongFiltering'; import { useFavoritesSelection } from '../hooks/useFavoritesSelection'; +import { useBulkPlPickerOutsideClick } from '../hooks/useBulkPlPickerOutsideClick'; import AlbumRow from '../components/AlbumRow'; import ArtistRow from '../components/ArtistRow'; import CachedImage from '../components/CachedImage'; @@ -120,7 +121,13 @@ export default function Favorites() { [selectedArtist, topFavoriteArtists], ); - const { toggleSelect } = useFavoritesSelection(songs, inSelectMode, tracklistRef); + const { toggleSelect } = useFavoritesSelection(visibleSongs, inSelectMode, tracklistRef); + + useBulkPlPickerOutsideClick(showPlPicker, setShowPlPicker); + + useEffect(() => { + if (!inSelectMode) setShowPlPicker(false); + }, [inSelectMode]); if (loading) { @@ -198,6 +205,7 @@ export default function Favorites() { currentYear={CURRENT_YEAR} inSelectMode={inSelectMode} selectedCount={selectedCount} + selectedIds={selectedIds} showPlPicker={showPlPicker} setShowPlPicker={setShowPlPicker} />