mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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
|
||||
/>
|
||||
|
||||
@@ -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