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
/>
+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}
/>