fix(favorites): bulk add-to-playlist and play/enqueue selected (#1140)

This commit is contained in:
cucadmuh
2026-06-21 14:29:05 +03:00
committed by GitHub
parent 30ccaf51a8
commit 986550c854
15 changed files with 100 additions and 33 deletions
+8
View File
@@ -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
@@ -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<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const songIdsRef = useRef(songIds);
songIdsRef.current = songIds;
const [adding, setAdding] = useState<string | null>(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 (
<div className="context-submenu" data-parent-trigger-id={triggerId ?? ''} ref={subRef} style={subStyle}>
<div
className="context-submenu"
data-parent-trigger-id={triggerId ?? ''}
ref={subRef}
style={subStyle}
onMouseDown={dropDown ? (e) => e.stopPropagation() : undefined}
>
{!creating ? (
<div
className="context-menu-item context-submenu-new"
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { ListPlus, Play, SlidersHorizontal, X } from 'lucide-react';
import type { SubsonicSong } from '../../api/subsonicTypes';
@@ -29,6 +29,7 @@ interface Props {
currentYear: number;
inSelectMode: boolean;
selectedCount: number;
selectedIds: ReadonlySet<string>;
showPlPicker: boolean;
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
}
@@ -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<string[]>([]);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
{/* Title Row with showing X of Y indicator */}
@@ -57,30 +67,30 @@ export default function FavoritesSongsSectionHeader({
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<div className="favorites-songs-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<button
className="btn btn-primary"
disabled={visibleSongs.length === 0}
disabled={targetSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
playTrack(tracks[0], tracks);
}}
>
<Play size={15} />
{t('favorites.playAll')}
{inSelectMode ? t('favorites.playSelected') : t('favorites.playAll')}
</button>
<button
className="btn btn-surface"
disabled={visibleSongs.length === 0}
disabled={targetSongs.length === 0}
onClick={() => {
if (visibleSongs.length === 0) return;
const tracks = visibleSongs.map(songToTrack);
if (targetSongs.length === 0) return;
const tracks = targetSongs.map(songToTrack);
enqueue(tracks);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
{inSelectMode ? t('favorites.enqueueSelected') : t('favorites.enqueueAll')}
</button>
{/* 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 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
<div className="bulk-action-toolbar" style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginLeft: 'auto' }}>
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
onClick={() => setShowPlPicker(v => !v)}
onClick={() => {
setShowPlPicker(prev => {
if (!prev) pickerSongIdsRef.current = [...selectedIds];
return !prev;
});
}}
>
<ListPlus size={14} />
{t('common.bulkAddToPlaylist')}
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
songIds={pickerSongIdsRef.current}
resolveSongIds={() => pickerSongIdsRef.current}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
+10 -9
View File
@@ -7,7 +7,7 @@ export interface FavoritesSelectionResult {
}
export function useFavoritesSelection(
songs: SubsonicSong[],
visibleSongs: SubsonicSong[],
inSelectMode: boolean,
tracklistRef: React.RefObject<HTMLDivElement | null>,
): 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 };
}
+2
View File
@@ -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}})',
+2
View File
@@ -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}})',
+2
View File
@@ -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}})',
+2
View File
@@ -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}})',
+2
View File
@@ -5,7 +5,9 @@ export const favorites = {
albums: 'アルバム',
songs: '曲',
enqueueAll: 'すべてキューに追加',
enqueueSelected: '選択をキューに追加',
playAll: 'すべて再生',
playSelected: '選択を再生',
removeSong: 'お気に入りから削除',
stations: 'ラジオ局',
showingFiltered: '{{total}} 件中 {{filtered}} 件を表示 ({{artist}})',
+2
View File
@@ -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}})',
+2
View File
@@ -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}})',
+2
View File
@@ -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}})',
+2
View File
@@ -5,7 +5,9 @@ export const favorites = {
albums: 'Альбомы',
songs: 'Треки',
enqueueAll: 'Всё в очередь',
enqueueSelected: 'Выбранное в очередь',
playAll: 'Воспроизвести всё',
playSelected: 'Воспроизвести выбранное',
removeSong: 'Убрать из избранного',
stations: 'Радиостанции',
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
+2
View File
@@ -5,7 +5,9 @@ export const favorites = {
albums: '专辑',
songs: '歌曲',
enqueueAll: '全部加入队列',
enqueueSelected: '所选加入队列',
playAll: '全部播放',
playSelected: '播放所选',
removeSong: '从收藏中移除',
stations: '广播电台',
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
+9 -1
View File
@@ -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}
/>