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
@@ -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
/>