mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
fix(favorites): bulk add-to-playlist and play/enqueue selected (#1140)
This commit is contained in:
@@ -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
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user