mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
fix(favorites): bulk add-to-playlist and play/enqueue selected (#1140)
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -5,7 +5,9 @@ export const favorites = {
|
||||
albums: 'アルバム',
|
||||
songs: '曲',
|
||||
enqueueAll: 'すべてキューに追加',
|
||||
enqueueSelected: '選択をキューに追加',
|
||||
playAll: 'すべて再生',
|
||||
playSelected: '選択を再生',
|
||||
removeSong: 'お気に入りから削除',
|
||||
stations: 'ラジオ局',
|
||||
showingFiltered: '{{total}} 件中 {{filtered}} 件を表示 ({{artist}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -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}})',
|
||||
|
||||
@@ -5,7 +5,9 @@ export const favorites = {
|
||||
albums: 'Альбомы',
|
||||
songs: 'Треки',
|
||||
enqueueAll: 'Всё в очередь',
|
||||
enqueueSelected: 'Выбранное в очередь',
|
||||
playAll: 'Воспроизвести всё',
|
||||
playSelected: 'Воспроизвести выбранное',
|
||||
removeSong: 'Убрать из избранного',
|
||||
stations: 'Радиостанции',
|
||||
showingFiltered: 'Показано {{filtered}} из {{total}} ({{artist}})',
|
||||
|
||||
@@ -5,7 +5,9 @@ export const favorites = {
|
||||
albums: '专辑',
|
||||
songs: '歌曲',
|
||||
enqueueAll: '全部加入队列',
|
||||
enqueueSelected: '所选加入队列',
|
||||
playAll: '全部播放',
|
||||
playSelected: '播放所选',
|
||||
removeSong: '从收藏中移除',
|
||||
stations: '广播电台',
|
||||
showingFiltered: '显示 {{filtered}} / {{total}} ({{artist}})',
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user