feat: add multi-selection and context menu for playlist management

This commit is contained in:
kveld9
2026-04-12 14:29:41 -03:00
parent 805b6bf163
commit bef6941a2b
12 changed files with 7955 additions and 99 deletions
+4 -1
View File
@@ -6,11 +6,12 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
import { useZipDownloadStore } from '../store/zipDownloadStore';
import { X, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { X, CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
type SortType = 'alphabeticalByName' | 'alphabeticalByArtist';
@@ -68,6 +69,7 @@ export default function Albums() {
};
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -284,6 +286,7 @@ export default function Albums() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+131 -42
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { getArtists, SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { LayoutGrid, List, Images, ChevronDown } from 'lucide-react';
import { LayoutGrid, List, Images, CheckSquare2, ListMusic, Check } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import CachedImage from '../components/CachedImage';
@@ -81,25 +81,55 @@ export default function Artists() {
const [letterFilter, setLetterFilter] = useState(ALL_SENTINEL);
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [visibleCount, setVisibleCount] = useState(50);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const PAGE_SIZE = showArtistImages ? 50 : 100; // Menor con imágenes para reducir I/O
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const showArtistImages = useAuthStore(s => s.showArtistImages);
const setShowArtistImages = useAuthStore(s => s.setShowArtistImages);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedArtists = artists.filter(a => selectedIds.has(a.id));
useEffect(() => {
getArtists().then(data => { setArtists(data); setLoading(false); }).catch(() => setLoading(false));
}, [musicLibraryFilterVersion]);
const loadMore = useCallback(() => {
setVisibleCount(prev => prev + 50);
}, []);
if (loadingMore) return;
setLoadingMore(true);
setVisibleCount(prev => prev + PAGE_SIZE);
setTimeout(() => setLoadingMore(false), 100);
}, [loadingMore, PAGE_SIZE]);
// Reset infinite scroll when filters change
// Reset infinite scroll when filters or image setting change
useEffect(() => {
setVisibleCount(50);
}, [filter, letterFilter, viewMode]);
setVisibleCount(PAGE_SIZE);
}, [filter, letterFilter, viewMode, PAGE_SIZE]);
// Filter pipeline
let filtered = artists;
@@ -120,6 +150,16 @@ export default function Artists() {
const visible = filtered.slice(0, visibleCount);
const hasMore = visibleCount < filtered.length;
// Intersection Observer for infinite scroll (after hasMore declaration)
useEffect(() => {
const observer = new IntersectionObserver(
entries => { if (entries[0].isIntersecting) loadMore(); },
{ rootMargin: '200px' }
);
if (observerTarget.current) observer.observe(observerTarget.current);
return () => observer.disconnect();
}, [loadMore, hasMore]);
// Group by first letter (for list view)
const groups: Record<string, SubsonicArtist[]> = {};
visible.forEach(a => {
@@ -134,7 +174,11 @@ export default function Artists() {
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1.5rem', flexWrap: 'wrap', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('artists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('artists.selectionCount', { count: selectedIds.size })
: t('artists.title')}
</h1>
<input
className="input"
style={{ maxWidth: 220 }}
@@ -146,30 +190,43 @@ export default function Artists() {
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{!(selectionMode && selectedIds.size > 0) && (<>
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
</button>
</>
)}
<button
className={`btn btn-surface`}
onClick={() => setShowArtistImages(!showArtistImages)}
style={showArtistImages ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={showArtistImages ? t('artists.imagesOn') : t('artists.imagesOff')}
data-tooltip-wrap
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('artists.cancelSelect') : t('artists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<Images size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'grid' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('grid')}
style={viewMode === 'grid' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.gridView')}
>
<LayoutGrid size={20} />
</button>
<button
className={`btn btn-surface ${viewMode === 'list' ? 'btn-sort-active' : ''}`}
onClick={() => setViewMode('list')}
style={viewMode === 'list' ? { background: 'var(--accent)', color: 'var(--ctp-crust)', padding: '0.5rem' } : { padding: '0.5rem' }}
data-tooltip={t('artists.listView')}
>
<List size={20} />
<CheckSquare2 size={15} />
{selectionMode ? t('artists.cancelSelect') : t('artists.select')}
</button>
</div>
</div>
@@ -193,13 +250,33 @@ export default function Artists() {
{visible.map(artist => (
<div
key={artist.id}
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-card${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}${selectionMode ? ' artist-card--selectable' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
style={selectionMode && selectedIds.has(artist.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`artist-card-select-check${selectedIds.has(artist.id) ? ' artist-card-select-check--on' : ''}`}>
{selectedIds.has(artist.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
<ArtistCardAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'center' }}>
<div className="artist-card-name">{artist.name}</div>
@@ -221,13 +298,27 @@ export default function Artists() {
{groups[letter].map(artist => (
<button
key={artist.id}
className="artist-row"
onClick={() => navigate(`/artist/${artist.id}`)}
className={`artist-row${selectionMode && selectedIds.has(artist.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(artist.id);
} else {
navigate(`/artist/${artist.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, artist, 'artist');
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedArtists, 'multi-artist');
} else {
openContextMenu(e.clientX, e.clientY, artist, 'artist');
}
}}
id={`artist-${artist.id}`}
style={selectionMode && selectedIds.has(artist.id) ? {
background: 'var(--accent-dim)',
color: 'var(--accent)'
} : {}}
>
<ArtistRowAvatar artist={artist} showImages={showArtistImages} />
<div style={{ textAlign: 'left' }}>
@@ -245,10 +336,8 @@ export default function Artists() {
)}
{!loading && hasMore && (
<div style={{ marginTop: 32, marginBottom: '2rem', display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={loadMore}>
<ChevronDown size={16} /> {t('artists.loadMore')}
</button>
<div ref={observerTarget} style={{ height: '20px', margin: '2rem 0', display: 'flex', justifyContent: 'center' }}>
{loadingMore && <div className="spinner" style={{ width: 20, height: 20 }} />}
</div>
)}
+4 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
import { getAlbumList, getAlbumsByGenre, getAlbum, SubsonicAlbum, buildDownloadUrl } from '../api/subsonic';
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import { useOfflineStore } from '../store/offlineStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
import { usePlayerStore } from '../store/playerStore';
import { invoke } from '@tauri-apps/api/core';
import { join } from '@tauri-apps/api/path';
import { showToast } from '../utils/toast';
@@ -50,6 +51,7 @@ export default function NewReleases() {
}, []);
const clearSelection = () => { setSelectionMode(false); setSelectedIds(new Set()); };
const selectedAlbums = albums.filter(a => selectedIds.has(a.id));
const openContextMenu = usePlayerStore(state => state.openContextMenu);
const handleDownloadZips = async () => {
if (selectedAlbums.length === 0) return;
@@ -183,6 +185,7 @@ export default function NewReleases() {
selectionMode={selectionMode}
selected={selectedIds.has(a.id)}
onToggleSelect={toggleSelect}
selectedAlbums={selectedAlbums}
/>
))}
</div>
+146 -39
View File
@@ -1,12 +1,13 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, X } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
import { usePlayerStore, songToTrack } from '../store/playerStore';
import { usePlaylistStore } from '../store/playlistStore';
import CachedImage from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
import { formatHumanHoursMinutes } from '../utils/formatHumanDuration';
import { showToast } from '../utils/toast';
function formatDuration(seconds: number): string {
return formatHumanHoursMinutes(seconds);
@@ -16,6 +17,7 @@ export default function Playlists() {
const { t } = useTranslation();
const navigate = useNavigate();
const playTrack = usePlayerStore(s => s.playTrack);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const removeId = usePlaylistStore((s) => s.removeId);
@@ -27,6 +29,30 @@ export default function Playlists() {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
// ── Multi-selection ──────────────────────────────────────────────────────
const [selectionMode, setSelectionMode] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelectionMode = () => {
setSelectionMode(v => !v);
setSelectedIds(new Set());
};
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}, []);
const clearSelection = () => {
setSelectionMode(false);
setSelectedIds(new Set());
};
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
useEffect(() => {
getPlaylists()
.then(setPlaylists)
@@ -78,6 +104,55 @@ export default function Playlists() {
setDeleteConfirmId(null);
};
const handleDeleteSelected = async () => {
if (selectedPlaylists.length === 0) return;
let deleted = 0;
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
setPlaylists((prev) => prev.filter((p) => !selectedIds.has(p.id)));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
}
};
const handleMergeSelected = async (targetPlaylist: SubsonicPlaylist) => {
if (selectedPlaylists.length === 0) return;
try {
const { songs: targetSongs } = await getPlaylist(targetPlaylist.id);
const targetIds = new Set(targetSongs.map(s => s.id));
let totalAdded = 0;
for (const pl of selectedPlaylists) {
if (pl.id === targetPlaylist.id) continue;
const { songs } = await getPlaylist(pl.id);
const newSongs = songs.filter(s => !targetIds.has(s.id));
if (newSongs.length > 0) {
newSongs.forEach(s => targetIds.add(s.id));
totalAdded += newSongs.length;
}
}
if (totalAdded > 0) {
await updatePlaylist(targetPlaylist.id, Array.from(targetIds));
touchPlaylist(targetPlaylist.id);
showToast(t('playlists.mergeSuccess', { count: totalAdded, playlist: targetPlaylist.name }), 3000, 'info');
} else {
showToast(t('playlists.mergeNoNewSongs'), 3000, 'info');
}
clearSelection();
} catch {
showToast(t('playlists.mergeError'), 4000, 'error');
}
};
if (loading) {
return (
<div className="content-body" style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
@@ -91,34 +166,51 @@ export default function Playlists() {
{/* ── Header row ── */}
<div className="playlists-header">
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('playlists.title')}</h1>
<h1 className="page-title" style={{ marginBottom: 0 }}>
{selectionMode && selectedIds.size > 0
? t('playlists.selectionCount', { count: selectedIds.size })
: t('playlists.title')}
</h1>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
{!(selectionMode && selectedIds.size > 0) && (<>
{creating ? (
<>
<input
ref={nameInputRef}
className="input"
style={{ width: 220 }}
placeholder={t('playlists.createName')}
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleCreate();
if (e.key === 'Escape') { setCreating(false); setNewName(''); }
}}
/>
<button className="btn btn-primary" onClick={handleCreate}>
{t('playlists.create')}
</button>
<button className="btn btn-surface" onClick={() => { setCreating(false); setNewName(''); }}>
{t('playlists.cancel')}
</button>
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
</>
) : (
<button className="btn btn-primary" onClick={() => setCreating(true)}>
<Plus size={15} /> {t('playlists.newPlaylist')}
</button>
)}
<button
className={`btn btn-surface${selectionMode ? ' btn-sort-active' : ''}`}
onClick={toggleSelectionMode}
data-tooltip={selectionMode ? t('playlists.cancelSelect') : t('playlists.startSelect')}
data-tooltip-pos="bottom"
style={selectionMode ? { background: 'var(--accent)', color: 'var(--ctp-crust)' } : {}}
>
<CheckSquare2 size={15} />
{selectionMode ? t('playlists.cancelSelect') : t('playlists.select')}
</button>
</div>
</div>
@@ -130,10 +222,34 @@ export default function Playlists() {
{playlists.map((pl) => (
<div
key={pl.id}
className="album-card"
onClick={() => navigate(`/playlists/${pl.id}`)}
className={`album-card${selectionMode && selectedIds.has(pl.id) ? ' selected' : ''}`}
onClick={() => {
if (selectionMode) {
toggleSelect(pl.id);
} else {
navigate(`/playlists/${pl.id}`);
}
}}
onContextMenu={(e) => {
e.preventDefault();
if (selectionMode && selectedIds.size > 0) {
openContextMenu(e.clientX, e.clientY, selectedPlaylists, 'multi-playlist');
} else {
openContextMenu(e.clientX, e.clientY, pl, 'playlist');
}
}}
onMouseLeave={() => { if (deleteConfirmId === pl.id) setDeleteConfirmId(null); }}
style={selectionMode && selectedIds.has(pl.id) ? {
outline: '2px solid var(--accent)',
outlineOffset: '2px',
borderRadius: 'var(--radius-md)'
} : {}}
>
{selectionMode && (
<div className={`album-card-select-check${selectedIds.has(pl.id) ? ' album-card-select-check--on' : ''}`}>
{selectedIds.has(pl.id) && <Check size={14} strokeWidth={3} />}
</div>
)}
{/* Cover area — server collage or fallback icon */}
<div className="album-card-cover">
{pl.coverArt ? (
@@ -163,15 +279,6 @@ export default function Playlists() {
</button>
</div>
{/* Delete button — top-right corner */}
<button
className={`playlist-card-delete ${deleteConfirmId === pl.id ? 'playlist-card-delete--confirm' : ''}`}
onClick={(e) => handleDelete(e, pl)}
data-tooltip={deleteConfirmId === pl.id ? t('playlists.confirmDelete') : t('playlists.deletePlaylist')}
data-tooltip-pos="bottom"
>
{deleteConfirmId === pl.id ? <Trash2 size={12} /> : <X size={12} />}
</button>
</div>
<div className="album-card-info">