feat: playlist management enhancements & UX improvements (#168)

- Multi-selection for Albums, Artists, Playlists with context menu bulk-add
- Collapsible playlist section in sidebar
- Infinite scroll on Artists page (IntersectionObserver)
- Submenu flip-up on viewport overflow
- Remove from Playlist in context menu
- All 8 locales synced

Fixes applied:
- title= → data-tooltip on playlist toggle button (CLAUDE.md)
- Hardcoded Spanish aria-label → i18n (sidebar.expandPlaylists/collapsePlaylists)
- AddToPlaylistSubmenu fetches playlists on first open if store empty

Co-Authored-By: kveld9 <kveld9@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-13 17:37:50 +02:00
26 changed files with 1944 additions and 13241 deletions
+1
View File
@@ -31,6 +31,7 @@ dist-ssr
# Tauri
src-tauri/target/
src-tauri/gen/
# Documentation
CLAUDE.md
File diff suppressed because one or more lines are too long
-1
View File
@@ -1 +0,0 @@
{"default":{"identifier":"default","description":"Default capabilities for Psysonic","local":true,"windows":["main"],"permissions":["core:default","shell:default",{"identifier":"shell:allow-open","allow":[{"url":"https://**"}]},"global-shortcut:allow-register","global-shortcut:allow-unregister","store:default","store:allow-load","store:allow-set","store:allow-get","store:allow-save","dialog:default","dialog:allow-open","dialog:allow-save","fs:default","fs:allow-write-file","fs:allow-read-file","fs:allow-mkdir","fs:scope-download-recursive","fs:scope-home-recursive","window-state:allow-save-window-state","window-state:allow-restore-state","core:window:allow-set-title","core:window:allow-close","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-hide","core:window:allow-show","core:window:allow-set-fullscreen","core:window:allow-is-fullscreen","core:window:allow-start-dragging","core:window:allow-create","core:webview:allow-create-webview-window","process:allow-restart"],"platforms":["linux","macOS","windows"]}}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+7 -2
View File
@@ -15,9 +15,10 @@ interface AlbumCardProps {
selectionMode?: boolean;
onToggleSelect?: (id: string) => void;
showRating?: boolean;
selectedAlbums?: SubsonicAlbum[];
}
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false }: AlbumCardProps) {
function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating = false, selectedAlbums = [] }: AlbumCardProps) {
const navigate = useNavigate();
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const serverId = useAuthStore(s => s.activeServerId ?? '');
@@ -44,7 +45,11 @@ function AlbumCard({ album, selected, selectionMode, onToggleSelect, showRating
onKeyDown={e => e.key === 'Enter' && handleClick()}
onContextMenu={(e) => {
e.preventDefault();
openContextMenu(e.clientX, e.clientY, album, 'album');
if (selectionMode && selectedAlbums.length > 0) {
openContextMenu(e.clientX, e.clientY, selectedAlbums, 'multi-album');
} else {
openContextMenu(e.clientX, e.clientY, album, 'album');
}
}}
onMouseDown={e => {
if (selectionMode || e.button !== 0) return;
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -1,7 +1,8 @@
import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
@@ -655,10 +656,9 @@ export default function QueuePanel() {
onClose={() => setSaveModalOpen(false)}
onSave={async (name) => {
try {
await createPlaylist(name, queue.map(t => t.id));
const playlists = await getPlaylists();
const created = playlists.find(p => p.name === name);
if (created) setActivePlaylist({ id: created.id, name: created.name });
const createPlaylist = usePlaylistStore.getState().createPlaylist;
const pl = await createPlaylist(name, queue.map(t => t.id));
if (pl) setActivePlaylist({ id: pl.id, name: pl.name });
setSaveModalOpen(false);
} catch (e) {
console.error('Failed to save playlist', e);
+81 -13
View File
@@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback } from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore';
@@ -10,10 +10,12 @@ import { useTranslation } from 'react-i18next';
import {
Disc3, Users, Music4, Radio, Settings, Heart, BarChart3,
PanelLeftClose, PanelLeft, HelpCircle, AudioLines, HardDriveDownload, Tags, ListMusic, Cast,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2,
ChevronDown, Check, Music2, TrendingUp, FolderOpen, X, Wand2, ChevronRight, PlayCircle,
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import { getPlaylists } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
// All configurable nav items — order and visibility controlled by sidebarStore.
// Exported so Settings can render the same item metadata.
@@ -56,6 +58,14 @@ export default function Sidebar({
const hasOfflineContent = Object.values(offlineAlbums).some(a => a.serverId === serverId);
const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const playlistsRaw = usePlaylistStore(s => s.playlists);
const playlistsLoading = usePlaylistStore(s => s.playlistsLoading);
const fetchPlaylists = usePlaylistStore(s => s.fetchPlaylists);
// Sort playlists alphabetically by name
const playlists = useMemo(() => {
return [...playlistsRaw].sort((a, b) => a.name.localeCompare(b.name));
}, [playlistsRaw]);
const [dropdownRect, setDropdownRect] = useState({ top: 0, left: 0, width: 0 });
const libraryTriggerRef = useRef<HTMLButtonElement>(null);
const showLibraryPicker = !isCollapsed && isLoggedIn && musicFolders.length > 1;
@@ -113,6 +123,12 @@ export default function Sidebar({
setLibraryDropdownOpen(false);
};
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
.filter(cfg => cfg.visible && ALL_NAV_ITEMS[cfg.id]?.section === 'library')
@@ -216,17 +232,69 @@ export default function Sidebar({
<span className="nav-section-label">{t('sidebar.library')}</span>
))}
{visibleLibrary.map(item => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
item.to === '/playlists' ? (
// Playlists item with expand button
<div key={item.to} className="sidebar-playlists-wrapper">
<div className="sidebar-playlists-header-row">
<NavLink
to={item.to}
className={({ isActive }) => `nav-link sidebar-playlists-main-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
{!isCollapsed && (
<button
className={`sidebar-playlists-toggle ${playlistsExpanded ? 'expanded' : ''}`}
onClick={() => setPlaylistsExpanded(!playlistsExpanded)}
aria-expanded={playlistsExpanded}
aria-label={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
data-tooltip={playlistsExpanded ? t('sidebar.collapsePlaylists') : t('sidebar.expandPlaylists')}
>
<ChevronRight size={14} />
</button>
)}
</div>
{!isCollapsed && playlistsExpanded && (
<div className="sidebar-playlists-list">
{playlistsLoading ? (
<div className="sidebar-playlists-loading">
<div className="spinner" style={{ width: 14, height: 14 }} />
</div>
) : playlists.length === 0 ? (
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
) : (
playlists.map((pl: { id: string; name: string }) => (
<NavLink
key={pl.id}
to={`/playlists/${pl.id}`}
className={({ isActive }) => `nav-link sidebar-playlist-item ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? pl.name : undefined}
data-tooltip-pos="bottom"
>
<PlayCircle size={12} />
<span>{pl.name}</span>
</NavLink>
))
)}
</div>
)}
</div>
) : (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className={({ isActive }) => `nav-link ${isActive ? 'active' : ''}`}
data-tooltip={isCollapsed ? t(item.labelKey) : undefined}
data-tooltip-pos="bottom"
>
<item.icon size={isCollapsed ? 22 : 18} />
{!isCollapsed && <span>{t(item.labelKey)}</span>}
</NavLink>
)
))}
{/* Now Playing — fixed, always visible */}
+38
View File
@@ -26,6 +26,8 @@ export const deTranslation = {
folderBrowser: 'Ordner-Browser',
libraryScope: 'Bibliotheksumfang',
allLibraries: 'Alle Bibliotheken',
expandPlaylists: 'Playlists ausklappen',
collapsePlaylists: 'Playlists einklappen',
},
home: {
hero: 'Featured',
@@ -106,10 +108,14 @@ export const deTranslation = {
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
unfavoriteAlbum: 'Album aus Favoriten entfernen',
removeFromQueue: 'Diesen Song entfernen',
removeFromPlaylist: 'Aus Playlist entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
download: 'Herunterladen (ZIP)',
addToPlaylist: 'Zur Playlist hinzufügen',
selectedPlaylists: '{{count}} Playlists ausgewählt',
selectedAlbums: '{{count}} Alben ausgewählt',
selectedArtists: '{{count}} Künstler ausgewählt',
songInfo: 'Song-Infos',
},
albumDetail: {
@@ -296,6 +302,11 @@ export const deTranslation = {
notFound: 'Keine Künstler gefunden.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Alben',
selectionCount: '{{count}} ausgewählt',
select: 'Mehrfachauswahl',
startSelect: 'Mehrfachauswahl aktivieren',
cancelSelect: 'Abbrechen',
addToPlaylist: 'Zur Playlist hinzufügen',
},
login: {
subtitle: 'Dein Navidrome Desktop Player',
@@ -921,6 +932,33 @@ export const deTranslation = {
coverUpdated: 'Cover aktualisiert',
metaSaved: 'Playlist aktualisiert',
downloadZip: 'Herunterladen (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addPartial: '{{added}} hinzugefügt, {{skipped}} übersprungen (Duplikate)',
addAllSkipped: 'Alle übersprungen ({{count}} Duplikate)',
addError: 'Fehler beim Hinzufügen von Songs',
createAndAddSuccess: 'Playlist "{{playlist}}" mit {{count}} Songs erstellt',
createError: 'Fehler beim Erstellen der Playlist',
deleteSuccess: '{{count}} Playlists gelöscht',
deleteFailed: 'Fehler beim Löschen von {{name}}',
deleteSelected: 'Ausgewählte löschen',
mergeSuccess: '{{count}} Songs in {{playlist}} zusammengeführt',
mergeNoNewSongs: 'Keine neuen Songs zum Hinzufügen',
mergeError: 'Fehler beim Zusammenführen von Playlists',
mergeInto: 'Zusammenführen in',
selectionCount: '{{count}} ausgewählt',
select: 'Auswählen',
startSelect: 'Auswahl aktivieren',
cancelSelect: 'Abbrechen',
loadingAlbums: '{{count}} Alben werden aufgelöst…',
loadingArtists: '{{count}} Künstler werden aufgelöst…',
myPlaylists: 'Meine Playlists',
noOtherPlaylists: 'Keine anderen Playlists verfügbar',
addToPlaylistSuccess: '{{count}} Songs zu {{playlist}} hinzugefügt',
addToPlaylistNoNew: 'Keine neuen Songs für {{playlist}}',
addToPlaylistError: 'Fehler beim Hinzufügen zur Playlist',
removeSuccess: 'Song aus Playlist entfernt',
removeError: 'Fehler beim Entfernen aus der Playlist',
},
mostPlayed: {
title: 'Meistgehört',
+38
View File
@@ -27,6 +27,8 @@ export const enTranslation = {
folderBrowser: 'Folder Browser',
libraryScope: 'Library scope',
allLibraries: 'All libraries',
expandPlaylists: 'Expand playlists',
collapsePlaylists: 'Collapse playlists',
},
home: {
hero: 'Featured',
@@ -107,10 +109,14 @@ export const enTranslation = {
unfavoriteArtist: 'Remove Artist from Favorites',
unfavoriteAlbum: 'Remove Album from Favorites',
removeFromQueue: 'Remove from Queue',
removeFromPlaylist: 'Remove from Playlist',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
download: 'Download (ZIP)',
addToPlaylist: 'Add to Playlist',
selectedPlaylists: '{{count}} playlists selected',
selectedAlbums: '{{count}} albums selected',
selectedArtists: '{{count}} artists selected',
songInfo: 'Song Info',
},
albumDetail: {
@@ -297,6 +303,11 @@ export const enTranslation = {
notFound: 'No artists found.',
albumCount_one: '{{count}} Album',
albumCount_other: '{{count}} Albums',
selectionCount: '{{count}} selected',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
addToPlaylist: 'Add to Playlist',
},
login: {
subtitle: 'Your Navidrome Desktop Player',
@@ -923,6 +934,33 @@ export const enTranslation = {
coverUpdated: 'Cover updated',
metaSaved: 'Playlist updated',
downloadZip: 'Download (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} songs added to {{playlist}}',
addPartial: '{{added}} added, {{skipped}} skipped (duplicates)',
addAllSkipped: 'All skipped ({{count}} duplicates)',
addError: 'Error adding songs',
createAndAddSuccess: 'Playlist "{{playlist}}" created with {{count}} songs',
createError: 'Error creating playlist',
deleteSuccess: '{{count}} playlists deleted',
deleteFailed: 'Error deleting {{name}}',
deleteSelected: 'Delete selected',
mergeSuccess: '{{count}} songs merged into {{playlist}}',
mergeNoNewSongs: 'No new songs to add',
mergeError: 'Error merging playlists',
mergeInto: 'Merge into',
selectionCount: '{{count}} selected',
select: 'Select',
startSelect: 'Enable selection',
cancelSelect: 'Cancel',
loadingAlbums: 'Resolving {{count}} albums…',
loadingArtists: 'Resolving {{count}} artists…',
myPlaylists: 'My Playlists',
noOtherPlaylists: 'No other playlists available',
addToPlaylistSuccess: '{{count}} songs added to {{playlist}}',
addToPlaylistNoNew: 'No new songs to add to {{playlist}}',
addToPlaylistError: 'Error adding to playlist',
removeSuccess: 'Song removed from playlist',
removeError: 'Error removing song from playlist',
},
mostPlayed: {
title: 'Most Played',
+52
View File
@@ -7,6 +7,7 @@ export const esTranslation = {
randomAlbums: 'Álbumes Aleatorios',
artists: 'Artistas',
randomMix: 'Mezcla Aleatoria',
randomPicker: 'Crear Mezcla',
favorites: 'Favoritos',
nowPlaying: 'Reproduciendo Ahora',
system: 'Sistema',
@@ -26,6 +27,8 @@ export const esTranslation = {
folderBrowser: 'Explorar Carpetas',
libraryScope: 'Ámbito de biblioteca',
allLibraries: 'Todas las bibliotecas',
expandPlaylists: 'Expandir listas',
collapsePlaylists: 'Colapsar listas',
},
home: {
hero: 'Destacado',
@@ -106,10 +109,14 @@ export const esTranslation = {
unfavoriteArtist: 'Quitar Artista de Favoritos',
unfavoriteAlbum: 'Quitar Álbum de Favoritos',
removeFromQueue: 'Quitar de la Cola',
removeFromPlaylist: 'Quitar de la Playlist',
openAlbum: 'Abrir Álbum',
goToArtist: 'Ir al Artista',
download: 'Descargar (ZIP)',
addToPlaylist: 'Agregar a Lista de Reproducción',
selectedPlaylists: '{{count}} listas seleccionadas',
selectedAlbums: '{{count}} álbumes seleccionados',
selectedArtists: '{{count}} artistas seleccionados',
songInfo: 'Información de la Canción',
},
albumDetail: {
@@ -204,6 +211,13 @@ export const esTranslation = {
removeSong: 'Quitar de favoritos',
stations: 'Estaciones de Radio',
},
randomLanding: {
title: 'Crear Mezcla',
mixByTracks: 'Mezcla por Canciones',
mixByTracksDesc: 'Selección aleatoria de canciones de toda tu biblioteca',
mixByAlbums: 'Mezcla por Álbumes',
mixByAlbumsDesc: 'Álbumes aleatorios para tu próximo descubrimiento',
},
randomAlbums: {
title: 'Álbumes Aleatorios',
refresh: 'Refrescar',
@@ -276,6 +290,7 @@ export const esTranslation = {
downloadZipFailed: 'Error al descargar {{name}}',
offlineQueuing: 'Encolando {{count}} álbum(es) para offline…',
offlineFailed: 'Error al agregar {{name}} offline',
addToPlaylist: 'Agregar a Lista de Reproducción',
},
artists: {
title: 'Artistas',
@@ -289,6 +304,11 @@ export const esTranslation = {
notFound: 'No se encontraron artistas.',
albumCount_one: '{{count}} Álbum',
albumCount_other: '{{count}} Álbumes',
selectionCount: '{{count}} seleccionados',
select: 'Selección múltiple',
startSelect: 'Activar selección múltiple',
cancelSelect: 'Cancelar',
addToPlaylist: 'Agregar a Lista',
},
login: {
subtitle: 'Tu Reproductor Navidrome para Escritorio',
@@ -580,6 +600,7 @@ export const esTranslation = {
shortcutSeekForward: 'Avanzar 10s',
shortcutSeekBackward: 'Retroceder 10s',
shortcutToggleQueue: 'Alternar cola',
shortcutOpenFolderBrowser: 'Abrir {{folderBrowser}}',
shortcutFullscreenPlayer: 'Reproductor pantalla completa',
shortcutNativeFullscreen: 'Pantalla completa nativa',
playbackTitle: 'Reproducción',
@@ -609,6 +630,10 @@ export const esTranslation = {
infiniteQueue: 'Cola Infinita',
infiniteQueueDesc: 'Agregar automáticamente pistas aleatorias cuando la cola termina',
experimental: 'Experimental',
fsPlayerSection: 'Reproductor Pantalla Completa',
fsShowArtistPortrait: 'Mostrar foto del artista',
fsShowArtistPortraitDesc: 'Muestra la foto del artista (o portada del álbum) en el lado derecho del reproductor pantalla completa.',
fsPortraitDim: 'Oscurecimiento de foto',
seekbarStyle: 'Estilo de Barra de Progreso',
seekbarStyleDesc: 'Elige la apariencia de la barra de progreso del reproductor',
seekbarWaveform: 'Forma de Onda',
@@ -910,6 +935,33 @@ export const esTranslation = {
coverUpdated: 'Portada actualizada',
metaSaved: 'Lista actualizada',
downloadZip: 'Descargar (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} canciones agregadas a {{playlist}}',
addPartial: '{{added}} agregadas, {{skipped}} skippeadas por duplicadas',
addAllSkipped: 'Todas skippeadas ({{count}} duplicadas)',
addError: 'Error al agregar canciones',
createAndAddSuccess: 'Lista "{{playlist}}" creada con {{count}} canciones',
createError: 'Error al crear lista',
deleteSuccess: '{{count}} listas eliminadas',
deleteFailed: 'Error al eliminar {{name}}',
deleteSelected: 'Eliminar seleccionadas',
mergeSuccess: '{{count}} canciones unificadas en {{playlist}}',
mergeNoNewSongs: 'No hay canciones nuevas para agregar',
mergeError: 'Error al unificar listas',
mergeInto: 'Unificar en',
selectionCount: '{{count}} seleccionadas',
select: 'Seleccionar',
startSelect: 'Activar selección',
cancelSelect: 'Cancelar',
loadingAlbums: 'Resolviendo {{count}} álbumes…',
loadingArtists: 'Resolviendo {{count}} artistas…',
myPlaylists: 'Mis Listas',
noOtherPlaylists: 'No hay otras listas disponibles',
addToPlaylistSuccess: '{{count}} canciones agregadas a {{playlist}}',
addToPlaylistNoNew: 'No hay canciones nuevas para agregar a {{playlist}}',
addToPlaylistError: 'Error al agregar a la lista',
removeSuccess: 'Canción quitada de la playlist',
removeError: 'Error al quitar la canción de la playlist',
},
mostPlayed: {
title: 'Más Reproducidos',
+52 -11
View File
@@ -26,6 +26,8 @@ export const frTranslation = {
folderBrowser: 'Explorateur de dossiers',
libraryScope: 'Portée de la bibliothèque',
allLibraries: 'Toutes les bibliothèques',
expandPlaylists: 'Développer les playlists',
collapsePlaylists: 'Réduire les playlists',
},
home: {
hero: 'En vedette',
@@ -106,10 +108,14 @@ export const frTranslation = {
unfavoriteArtist: 'Retirer l\'artiste des favoris',
unfavoriteAlbum: 'Retirer l\'album des favoris',
removeFromQueue: 'Retirer de la file',
removeFromPlaylist: 'Retirer de la playlist',
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
download: 'Télécharger (ZIP)',
addToPlaylist: 'Ajouter à la playlist',
selectedPlaylists: '{{count}} playlists sélectionnées',
selectedAlbums: '{{count}} albums sélectionnés',
selectedArtists: '{{count}} artistes sélectionnés',
songInfo: 'Infos du morceau',
},
albumDetail: {
@@ -272,17 +278,17 @@ export const frTranslation = {
yearTo: 'À',
yearFilterClear: 'Effacer le filtre année',
yearFilterLabel: 'Année',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
selectionCount: '{{count}} sélectionnés',
downloadZips: 'Télécharger les ZIPs',
addOffline: 'Ajouter hors ligne',
downloadingZip: 'Téléchargement {{current}}/{{total}} : {{name}}',
downloadZipDone: '{{count}} ZIP(s) téléchargé(s)',
downloadZipFailed: 'Échec du téléchargement de {{name}}',
offlineQueuing: 'Mise en file d\'attente de {{count}} album(s) hors ligne…',
offlineFailed: 'Échec de l\'ajout de {{name}} hors ligne',
},
artists: {
title: 'Artistes',
@@ -296,6 +302,11 @@ export const frTranslation = {
notFound: 'Aucun artiste trouvé.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} sélectionnés',
select: 'Sélection multiple',
startSelect: 'Activer la sélection multiple',
cancelSelect: 'Annuler',
addToPlaylist: 'Ajouter à la playlist',
},
login: {
subtitle: 'Votre lecteur de bureau Navidrome',
@@ -919,6 +930,33 @@ export const frTranslation = {
coverUpdated: 'Pochette mise à jour',
metaSaved: 'Playlist mise à jour',
downloadZip: 'Télécharger (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addPartial: '{{added}} ajoutés, {{skipped}} ignorés (doublons)',
addAllSkipped: 'Tous ignorés ({{count}} doublons)',
addError: 'Erreur lors de l\'ajout des morceaux',
createAndAddSuccess: 'Playlist "{{playlist}}" créée avec {{count}} morceaux',
createError: 'Erreur lors de la création de la playlist',
deleteSuccess: '{{count}} playlists supprimées',
deleteFailed: 'Erreur lors de la suppression de {{name}}',
deleteSelected: 'Supprimer la sélection',
mergeSuccess: '{{count}} morceaux fusionnés dans {{playlist}}',
mergeNoNewSongs: 'Aucun nouveau morceau à ajouter',
mergeError: 'Erreur lors de la fusion des playlists',
mergeInto: 'Fusionner dans',
selectionCount: '{{count}} sélectionnés',
select: 'Sélectionner',
startSelect: 'Activer la sélection',
cancelSelect: 'Annuler',
loadingAlbums: 'Résolution de {{count}} albums…',
loadingArtists: 'Résolution de {{count}} artistes…',
myPlaylists: 'Mes Playlists',
noOtherPlaylists: 'Aucune autre playlist disponible',
addToPlaylistSuccess: '{{count}} morceaux ajoutés à {{playlist}}',
addToPlaylistNoNew: 'Aucun nouveau morceau pour {{playlist}}',
addToPlaylistError: 'Erreur lors de l\'ajout à la playlist',
removeSuccess: 'Morceau retiré de la playlist',
removeError: 'Erreur lors du retrait de la playlist',
},
mostPlayed: {
title: 'Les plus joués',
@@ -929,6 +967,9 @@ export const frTranslation = {
sortLeast: 'Moins joués en premier',
loadMore: 'Charger plus d\'albums',
noData: 'Aucune donnée d\'écoute. Commencez à écouter !',
noArtists: 'Tous les artistes filtrés.',
filterCompilations: 'Masquer les artistes de compilations (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Masquer les compilations',
},
radio: {
title: 'Radio Internet',
+52 -11
View File
@@ -26,6 +26,8 @@ export const nbTranslation = {
folderBrowser: 'Mappeleser',
libraryScope: 'Biblioteksomfang',
allLibraries: 'Alle biblioteker',
expandPlaylists: 'Utvid spillelister',
collapsePlaylists: 'Skjul spillelister',
},
home: {
hero: 'Utvalgt',
@@ -106,10 +108,14 @@ export const nbTranslation = {
unfavoriteArtist: 'Fjern artist fra favoritter',
unfavoriteAlbum: 'Fjern album fra favoritter',
removeFromQueue: 'Fjern fra kø',
removeFromPlaylist: 'Fjern fra spilleliste',
openAlbum: 'Åpne album',
goToArtist: 'Gå til artist',
download: 'Last ned (ZIP)',
addToPlaylist: 'Legg til i spilleliste',
selectedPlaylists: '{{count}} spillelister valgt',
selectedAlbums: '{{count}} album valgt',
selectedArtists: '{{count}} artister valgt',
songInfo: 'Sanginfo',
},
albumDetail: {
@@ -272,17 +278,17 @@ export const nbTranslation = {
yearTo: 'Til',
yearFilterClear: 'Tøm år filteret',
yearFilterLabel: 'År',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
selectionCount: '{{count}} valgt',
downloadZips: 'Last ned ZIPs',
addOffline: 'Legg til offline',
downloadingZip: 'Laster ned {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) lastet ned',
downloadZipFailed: 'Kunne ikke laste ned {{name}}',
offlineQueuing: 'Legger {{count}} album i kø for offline…',
offlineFailed: 'Kunne ikke legge til {{name}} offline',
},
artists: {
title: 'Artister',
@@ -296,6 +302,11 @@ export const nbTranslation = {
notFound: 'Ingen artister funnet.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} album',
selectionCount: '{{count}} valgt',
select: 'Multivalg',
startSelect: 'Aktiver multivalg',
cancelSelect: 'Avbryt',
addToPlaylist: 'Legg til i spilleliste',
},
login: {
subtitle: 'Din Navidrome-mediaspiller',
@@ -918,6 +929,33 @@ export const nbTranslation = {
coverUpdated: 'Omslag oppdatert',
metaSaved: 'Spillelisten er oppdatert',
downloadZip: 'Last ned (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} sanger lagt til i {{playlist}}',
addPartial: '{{added}} lagt til, {{skipped}} hoppet over (duplikater)',
addAllSkipped: 'Alle hoppet over ({{count}} duplikater)',
addError: 'Feil ved å legge til sanger',
createAndAddSuccess: 'Spilleliste "{{playlist}}" opprettet med {{count}} sanger',
createError: 'Feil ved oppretting av spilleliste',
deleteSuccess: '{{count}} spillelister slettet',
deleteFailed: 'Feil ved sletting av {{name}}',
deleteSelected: 'Slett valgte',
mergeSuccess: '{{count}} sanger slått sammen i {{playlist}}',
mergeNoNewSongs: 'Ingen nye sanger å legge til',
mergeError: 'Feil ved sammenslåing av spillelister',
mergeInto: 'Slå sammen i',
selectionCount: '{{count}} valgt',
select: 'Velg',
startSelect: 'Aktiver valg',
cancelSelect: 'Avbryt',
loadingAlbums: 'Løser {{count}} album…',
loadingArtists: 'Løser {{count}} artister…',
myPlaylists: 'Mine spillelister',
noOtherPlaylists: 'Ingen andre spillelister tilgjengelig',
addToPlaylistSuccess: '{{count}} sanger lagt til i {{playlist}}',
addToPlaylistNoNew: 'Ingen nye sanger for {{playlist}}',
addToPlaylistError: 'Feil ved å legge til i spilleliste',
removeSuccess: 'Sang fjernet fra spilleliste',
removeError: 'Feil ved fjerning fra spilleliste',
},
mostPlayed: {
title: 'Mest spilt',
@@ -928,6 +966,9 @@ export const nbTranslation = {
sortLeast: 'Minst spilt først',
loadMore: 'Last inn flere album',
noData: 'Ingen avspillingsdata ennå. Begynn å høre!',
noArtists: 'Alle artister filtrert bort.',
filterCompilations: 'Skjul kompilasjonsartister (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Skjul kompilasjoner',
},
radio: {
title: 'Internettradio',
+49 -11
View File
@@ -26,6 +26,8 @@ export const nlTranslation = {
folderBrowser: 'Mappenverkenner',
libraryScope: 'Bibliotheekbereik',
allLibraries: 'Alle bibliotheken',
expandPlaylists: 'Afspeellijsten uitklappen',
collapsePlaylists: 'Afspeellijsten inklappen',
},
home: {
hero: 'Uitgelicht',
@@ -110,6 +112,9 @@ export const nlTranslation = {
goToArtist: 'Naar artiest',
download: 'Downloaden (ZIP)',
addToPlaylist: 'Toevoegen aan playlist',
selectedPlaylists: '{{count}} playlists geselecteerd',
selectedAlbums: '{{count}} albums geselecteerd',
selectedArtists: '{{count}} artiesten geselecteerd',
songInfo: 'Nummerinfo',
},
albumDetail: {
@@ -272,17 +277,17 @@ export const nlTranslation = {
yearTo: 'Tot',
yearFilterClear: 'Jaarfilter wissen',
yearFilterLabel: 'Jaar',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
selectionCount: '{{count}} geselecteerd',
downloadZips: 'ZIPs downloaden',
addOffline: 'Offline toevoegen',
downloadingZip: 'Downloaden {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) gedownload',
downloadZipFailed: 'Downloaden van {{name}} mislukt',
offlineQueuing: '{{count}} album(s) in wachtrij voor offline…',
offlineFailed: 'Toevoegen van {{name}} offline mislukt',
},
artists: {
title: 'Artiesten',
@@ -296,6 +301,11 @@ export const nlTranslation = {
notFound: 'Geen artiesten gevonden.',
albumCount_one: '{{count}} album',
albumCount_other: '{{count}} albums',
selectionCount: '{{count}} geselecteerd',
select: 'Meervoudige selectie',
startSelect: 'Meervoudige selectie inschakelen',
cancelSelect: 'Annuleren',
addToPlaylist: 'Toevoegen aan playlist',
},
login: {
subtitle: 'Jouw Navidrome-desktopspeler',
@@ -919,6 +929,31 @@ export const nlTranslation = {
coverUpdated: 'Omslag bijgewerkt',
metaSaved: 'Playlist bijgewerkt',
downloadZip: 'Downloaden (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
addPartial: '{{added}} toegevoegd, {{skipped}} overgeslagen (duplicaten)',
addAllSkipped: 'Alles overgeslagen ({{count}} duplicaten)',
addError: 'Fout bij toevoegen van nummers',
createAndAddSuccess: 'Playlist "{{playlist}}" aangemaakt met {{count}} nummers',
createError: 'Fout bij aanmaken van playlist',
deleteSuccess: '{{count}} playlists verwijderd',
deleteFailed: 'Fout bij verwijderen van {{name}}',
deleteSelected: 'Geselecteerde verwijderen',
mergeSuccess: '{{count}} nummers samengevoegd in {{playlist}}',
mergeNoNewSongs: 'Geen nieuwe nummers om toe te voegen',
mergeError: 'Fout bij samenvoegen van playlists',
mergeInto: 'Samenvoegen in',
selectionCount: '{{count}} geselecteerd',
select: 'Selecteren',
startSelect: 'Selectie inschakelen',
cancelSelect: 'Annuleren',
loadingAlbums: '{{count}} albums oplossen…',
loadingArtists: '{{count}} artiesten oplossen…',
myPlaylists: 'Mijn playlists',
noOtherPlaylists: 'Geen andere playlists beschikbaar',
addToPlaylistSuccess: '{{count}} nummers toegevoegd aan {{playlist}}',
addToPlaylistNoNew: 'Geen nieuwe nummers voor {{playlist}}',
addToPlaylistError: 'Fout bij toevoegen aan playlist',
},
mostPlayed: {
title: 'Meest gespeeld',
@@ -929,6 +964,9 @@ export const nlTranslation = {
sortLeast: 'Minst gespeeld eerst',
loadMore: 'Meer albums laden',
noData: 'Nog geen afspeelgegevens. Begin met luisteren!',
noArtists: 'Alle artiesten gefilterd.',
filterCompilations: 'Compilatie-artiesten verbergen (Various Artists, Soundtracks, etc.)',
filterCompilationsShort: 'Compilaties verbergen',
},
radio: {
title: 'Internetradio',
+53 -12
View File
@@ -27,6 +27,8 @@ export const ruTranslation = {
folderBrowser: 'Браузер папок',
libraryScope: 'Область медиатеки',
allLibraries: 'Все библиотеки',
expandPlaylists: 'Развернуть плейлисты',
collapsePlaylists: 'Свернуть плейлисты',
},
home: {
hero: 'Подборка',
@@ -107,10 +109,14 @@ export const ruTranslation = {
unfavoriteArtist: 'Убрать исполнителя из избранного',
unfavoriteAlbum: 'Убрать альбом из избранного',
removeFromQueue: 'Убрать из очереди',
removeFromPlaylist: 'Убрать из плейлиста',
openAlbum: 'Открыть альбом',
goToArtist: 'К исполнителю',
download: 'Скачать (ZIP)',
addToPlaylist: 'В плейлист',
selectedPlaylists: '{{count}} плейлистов выбрано',
selectedAlbums: '{{count}} альбомов выбрано',
selectedArtists: '{{count}} исполнителей выбрано',
songInfo: 'Сведения о треке',
},
albumDetail: {
@@ -281,17 +287,17 @@ export const ruTranslation = {
yearTo: 'По',
yearFilterClear: 'Сбросить год',
yearFilterLabel: 'Год',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
selectionCount: '{{count}} выбрано',
downloadZips: 'Скачать ZIP-архивы',
addOffline: 'Добавить офлайн',
downloadingZip: 'Загрузка {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP-архив(ов) загружено',
downloadZipFailed: 'Не удалось скачать {{name}}',
offlineQueuing: 'Добавление {{count}} альбом(ов) в офлайн…',
offlineFailed: 'Не удалось добавить {{name}} офлайн',
},
artists: {
title: 'Исполнители',
@@ -307,6 +313,11 @@ export const ruTranslation = {
albumCount_few: '{{count}} альбома',
albumCount_many: '{{count}} альбомов',
albumCount_other: '{{count}} альбомов',
selectionCount: '{{count}} выбрано',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист',
},
login: {
subtitle: 'Десктопный клиент для Navidrome',
@@ -978,6 +989,33 @@ export const ruTranslation = {
coverUpdated: 'Обложка обновлена',
metaSaved: 'Плейлист сохранён',
downloadZip: 'Скачать (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} треков добавлено в {{playlist}}',
addPartial: '{{added}} добавлено, {{skipped}} пропущено (дубликаты)',
addAllSkipped: 'Все пропущены ({{count}} дубликатов)',
addError: 'Ошибка при добавлении треков',
createAndAddSuccess: 'Плейлист "{{playlist}}" создан с {{count}} треками',
createError: 'Ошибка при создании плейлиста',
deleteSuccess: '{{count}} плейлистов удалено',
deleteFailed: 'Ошибка при удалении {{name}}',
deleteSelected: 'Удалить выбранные',
mergeSuccess: '{{count}} треков объединено в {{playlist}}',
mergeNoNewSongs: 'Нет новых треков для добавления',
mergeError: 'Ошибка при объединении плейлистов',
mergeInto: 'Объединить в',
selectionCount: '{{count}} выбрано',
select: 'Выбрать',
startSelect: 'Включить выбор',
cancelSelect: 'Отмена',
loadingAlbums: 'Загрузка {{count}} альбомов…',
loadingArtists: 'Загрузка {{count}} исполнителей…',
myPlaylists: 'Мои плейлисты',
noOtherPlaylists: 'Нет других доступных плейлистов',
addToPlaylistSuccess: '{{count}} треков добавлено в {{playlist}}',
addToPlaylistNoNew: 'Нет новых треков для {{playlist}}',
addToPlaylistError: 'Ошибка при добавлении в плейлист',
removeSuccess: 'Трек убран из плейлиста',
removeError: 'Ошибка при удалении из плейлиста',
},
mostPlayed: {
title: 'Популярное',
@@ -987,7 +1025,10 @@ export const ruTranslation = {
sortMost: 'Сначала популярные',
sortLeast: 'Сначала малоизвестные',
loadMore: 'Загрузить больше альбомов',
noData: 'Нет данных о прослушиваниях. Начните слушать!',
noData: 'Пока нет данных о прослушивании. Начните слушать!',
noArtists: 'Все исполнители отфильтрованы.',
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
filterCompilationsShort: 'Скрыть сборники',
},
radio: {
title: 'Онлайн-радио',
+50 -12
View File
@@ -26,6 +26,8 @@ export const zhTranslation = {
folderBrowser: '文件夹浏览器',
libraryScope: '资料库范围',
allLibraries: '所有资料库',
expandPlaylists: '展开播放列表',
collapsePlaylists: '收起播放列表',
},
home: {
hero: '精选',
@@ -110,6 +112,9 @@ export const zhTranslation = {
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
selectedPlaylists: '已选择 {{count}} 个播放列表',
selectedAlbums: '已选择 {{count}} 个专辑',
selectedArtists: '已选择 {{count}} 个艺术家',
songInfo: '歌曲信息',
},
albumDetail: {
@@ -272,17 +277,17 @@ export const zhTranslation = {
yearTo: '到',
yearFilterClear: '清除年份筛选',
yearFilterLabel: '年份',
select: 'Multi-select',
startSelect: 'Enable multi-select',
cancelSelect: 'Cancel',
selectionCount: '{{count}} selected',
downloadZips: 'Download ZIPs',
addOffline: 'Add Offline',
downloadingZip: 'Downloading {{current}}/{{total}}: {{name}}',
downloadZipDone: '{{count}} ZIP(s) downloaded',
downloadZipFailed: 'Failed to download {{name}}',
offlineQueuing: 'Queuing {{count}} album(s) for offline…',
offlineFailed: 'Failed to add {{name}} offline',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
selectionCount: '{{count}} 已选择',
downloadZips: '下载 ZIP',
addOffline: '添加离线',
downloadingZip: '正在下载 {{current}}/{{total}}: {{name}}',
downloadZipDone: '已下载 {{count}} ZIP',
downloadZipFailed: '下载 {{name}} 失败',
offlineQueuing: '正在将 {{count}} 张专辑加入离线队列…',
offlineFailed: '添加 {{name}} 离线失败',
},
artists: {
title: '艺术家',
@@ -296,6 +301,11 @@ export const zhTranslation = {
notFound: '未找到艺术家。',
albumCount_one: '{{count}} 张专辑',
albumCount_other: '{{count}} 张专辑',
selectionCount: '{{count}} 已选择',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
addToPlaylist: '添加到播放列表',
},
login: {
subtitle: '您的 Navidrome 桌面播放器',
@@ -915,6 +925,31 @@ export const zhTranslation = {
coverUpdated: '封面已更新',
metaSaved: '播放列表已更新',
downloadZip: '下载 (ZIP)',
// Toast notifications for multi-select
addSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addPartial: '{{added}} 首已添加,{{skipped}} 首跳过(重复)',
addAllSkipped: '全部跳过({{count}} 首重复)',
addError: '添加歌曲时出错',
createAndAddSuccess: '播放列表 "{{playlist}}" 已创建,包含 {{count}} 首歌曲',
createError: '创建播放列表时出错',
deleteSuccess: '{{count}} 个播放列表已删除',
deleteFailed: '删除 {{name}} 时出错',
deleteSelected: '删除所选',
mergeSuccess: '{{count}} 首歌曲已合并到 {{playlist}}',
mergeNoNewSongs: '没有新歌曲可添加',
mergeError: '合并播放列表时出错',
mergeInto: '合并到',
selectionCount: '{{count}} 已选择',
select: '选择',
startSelect: '启用选择',
cancelSelect: '取消',
loadingAlbums: '正在解析 {{count}} 张专辑…',
loadingArtists: '正在解析 {{count}} 位艺术家…',
myPlaylists: '我的播放列表',
noOtherPlaylists: '没有其他可用播放列表',
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
addToPlaylistError: '添加到播放列表时出错',
},
mostPlayed: {
title: '最常播放',
@@ -924,7 +959,10 @@ export const zhTranslation = {
sortMost: '最多播放在前',
sortLeast: '最少播放在前',
loadMore: '加载更多专辑',
noData: '暂无播放数据开始听吧!',
noData: '暂无播放数据开始听吧!',
noArtists: '所有艺术家已过滤。',
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
filterCompilationsShort: '隐藏合辑',
},
radio: {
title: '网络电台',
+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>
+10 -2
View File
@@ -57,6 +57,7 @@ const PL_COLUMNS: readonly ColDef[] = [
{ key: 'num', i18nKey: null, minWidth: 60, defaultWidth: 60, required: true },
{ key: 'title', i18nKey: 'trackTitle', minWidth: 150, defaultWidth: 0, required: true, flex: true },
{ key: 'artist', i18nKey: 'trackArtist', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'album', i18nKey: 'trackAlbum', minWidth: 80, defaultWidth: 180, required: false },
{ key: 'favorite', i18nKey: 'trackFavorite', minWidth: 50, defaultWidth: 70, required: false },
{ key: 'rating', i18nKey: 'trackRating', minWidth: 80, defaultWidth: 120, required: false },
{ key: 'duration', i18nKey: 'trackDuration', minWidth: 72, defaultWidth: 92, required: false },
@@ -233,6 +234,8 @@ export default function PlaylistDetail() {
}, [contextMenuOpen]);
// ── Load ─────────────────────────────────────────────────────
const lastModified = usePlaylistStore(s => (id ? s.lastModified[id] : undefined));
useEffect(() => {
if (!id) return;
setLoading(true);
@@ -252,7 +255,7 @@ export default function PlaylistDetail() {
})
.catch(() => {})
.finally(() => setLoading(false));
}, [id]);
}, [id, lastModified]);
// ── Suggestions ───────────────────────────────────────────────
const loadSuggestions = useCallback(async (currentSongs: SubsonicSong[]) => {
@@ -1018,7 +1021,7 @@ export default function PlaylistDetail() {
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
openContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song', undefined, id, realIdx);
}}
>
{visibleCols.map(colDef => {
@@ -1040,6 +1043,11 @@ export default function PlaylistDetail() {
<span className={`track-artist${song.artistId ? ' track-artist-link' : ''}`} style={{ cursor: song.artistId ? 'pointer' : 'default' }} onClick={e => { if (song.artistId) { e.stopPropagation(); navigate(`/artist/${song.artistId}`); } }}>{song.artist}</span>
</div>
);
case 'album': return (
<div key="album" className="track-artist-cell">
<span className={`track-artist${song.albumId ? ' track-artist-link' : ''}`} style={{ cursor: song.albumId ? 'pointer' : 'default' }} onClick={e => { if (song.albumId) { e.stopPropagation(); navigate(`/album/${song.albumId}`); } }}>{song.album}</span>
</div>
);
case 'favorite': return (
<div key="favorite" className="track-star-cell">
<button className="btn btn-ghost track-star-btn" onClick={e => handleToggleStar(song, e)} style={{ color: (song.id in starredOverrides ? starredOverrides[song.id] : starredSongs.has(song.id)) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}>
+165 -52
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, 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,10 +17,13 @@ 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);
const playlists = usePlaylistStore((s) => s.playlists);
const fetchPlaylists = usePlaylistStore((s) => s.fetchPlaylists);
const playlistsLoading = usePlaylistStore((s) => s.playlistsLoading);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
@@ -27,24 +31,45 @@ export default function Playlists() {
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
const nameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false));
// ── 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(() => {
fetchPlaylists().finally(() => setLoading(false));
}, [fetchPlaylists]);
useEffect(() => {
if (creating) nameInputRef.current?.focus();
}, [creating]);
const createPlaylist = usePlaylistStore(s => s.createPlaylist);
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
await createPlaylist(name);
const updated = await getPlaylists();
setPlaylists(updated);
} catch {}
await createPlaylist(name);
// Refresh playlists from API to get the new one
await fetchPlaylists();
setCreating(false);
setNewName('');
};
@@ -73,11 +98,67 @@ export default function Playlists() {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
setPlaylists((prev) => prev.filter((p) => p.id !== pl.id));
} catch {}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== pl.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
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');
}
}
usePlaylistStore.setState((s) => ({
playlists: s.playlists.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 +172,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 +228,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 +285,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">
+6 -4
View File
@@ -174,10 +174,12 @@ interface PlayerState {
x: number;
y: number;
item: any;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | null;
type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist' | null;
queueIndex?: number;
playlistId?: string;
playlistSongIndex?: number;
};
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song', queueIndex?: number) => void;
openContextMenu: (x: number, y: number, item: any, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song' | 'playlist' | 'multi-album' | 'multi-artist' | 'multi-playlist', queueIndex?: number, playlistId?: string, playlistSongIndex?: number) => void;
closeContextMenu: () => void;
songInfoModal: { isOpen: boolean; songId: string | null };
@@ -729,8 +731,8 @@ export const usePlayerStore = create<PlayerState>()(
repeatMode: 'off',
contextMenu: { isOpen: false, x: 0, y: 0, item: null, type: null },
openContextMenu: (x, y, item, type, queueIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex },
openContextMenu: (x, y, item, type, queueIndex, playlistId, playlistSongIndex) => set({
contextMenu: { isOpen: true, x, y, item, type, queueIndex, playlistId, playlistSongIndex },
}),
closeContextMenu: () => set(state => ({
contextMenu: { ...state.contextMenu, isOpen: false },
+38 -1
View File
@@ -1,22 +1,59 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { getPlaylists, createPlaylist as apiCreatePlaylist, SubsonicPlaylist } from '../api/subsonic';
interface PlaylistStore {
recentIds: string[];
playlists: SubsonicPlaylist[];
playlistsLoading: boolean;
lastModified: Record<string, number>;
touchPlaylist: (id: string) => void;
removeId: (id: string) => void;
fetchPlaylists: () => Promise<void>;
createPlaylist: (name: string, songIds?: string[]) => Promise<SubsonicPlaylist | null>;
addPlaylist: (playlist: SubsonicPlaylist) => void;
}
export const usePlaylistStore = create<PlaylistStore>()(
persist(
(set) => ({
(set, get) => ({
recentIds: [],
playlists: [],
playlistsLoading: false,
lastModified: {},
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
lastModified: { ...s.lastModified, [id]: Date.now() },
})),
removeId: (id) =>
set((s) => ({ recentIds: s.recentIds.filter((x) => x !== id) })),
fetchPlaylists: async () => {
set({ playlistsLoading: true });
try {
const playlists = await getPlaylists();
set({ playlists, playlistsLoading: false });
} catch {
set({ playlistsLoading: false });
}
},
createPlaylist: async (name: string, songIds?: string[]) => {
try {
const playlist = await apiCreatePlaylist(name, songIds);
set((s) => ({
playlists: [...s.playlists, playlist],
recentIds: [playlist.id, ...s.recentIds.filter((x) => x !== playlist.id)].slice(0, 50),
}));
return playlist;
} catch {
return null;
}
},
addPlaylist: (playlist) => {
set((s) => ({
playlists: [...s.playlists, playlist],
}));
},
}),
{ name: 'psysonic_playlists_recent' }
)
+29
View File
@@ -198,6 +198,7 @@
/* ─ Album Card ─ */
.album-card {
position: relative;
cursor: pointer;
background: var(--bg-card);
border-radius: var(--radius-lg);
@@ -519,6 +520,33 @@
color: var(--ctp-crust);
}
/* ─── Artist Card Selection Checkbox ─── */
.artist-card {
position: relative;
}
.artist-card-select-check {
position: absolute;
top: 6px;
left: 6px;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
border: 2px solid var(--border-subtle);
background: var(--bg-elevated);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
pointer-events: none;
}
.artist-card-select-check--on {
background: var(--accent);
border-color: var(--accent);
color: var(--ctp-crust);
}
/* ── Albums selection bar (portal) ── */
.albums-selection-bar {
position: fixed;
@@ -3685,6 +3713,7 @@ html.no-compositing .fs-lyrics-rail {
backdrop-filter: blur(16px);
color: var(--text-primary);
font-size: 13px;
outline: none;
}
.context-menu-item {
+140
View File
@@ -2306,3 +2306,143 @@
color: inherit;
opacity: 0.7;
}
/* ─── Sidebar Expandable Playlists Section ─── */
.sidebar-playlists-wrapper {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header-row {
display: flex;
align-items: center;
gap: var(--space-1);
padding-right: var(--space-2);
}
.sidebar-playlists-main-link {
flex: 1;
min-width: 0;
}
.sidebar-playlists-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border-radius: var(--radius-md);
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
transition: all var(--transition-fast);
flex-shrink: 0;
}
.sidebar-playlists-toggle:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-toggle.expanded {
transform: rotate(90deg);
color: var(--accent);
}
.sidebar-playlists-section {
display: flex;
flex-direction: column;
}
.sidebar-playlists-header {
display: flex;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
transition: background var(--transition-fast), color var(--transition-fast);
cursor: pointer;
background: transparent;
border: none;
text-align: left;
}
.sidebar-playlists-header:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlists-header.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-chevron {
margin-left: auto;
transition: transform 0.2s ease;
flex-shrink: 0;
}
.sidebar-playlists-chevron.expanded {
transform: rotate(90deg);
}
.sidebar-playlists-list {
display: flex;
flex-direction: column;
padding-left: var(--space-4);
padding-top: var(--space-1);
padding-bottom: var(--space-1);
gap: var(--space-1);
}
.sidebar-playlist-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
color: var(--text-secondary);
font-size: 13px;
font-weight: 400;
transition: background var(--transition-fast), color var(--transition-fast);
overflow: hidden;
}
.sidebar-playlist-item span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.sidebar-playlist-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.sidebar-playlist-item.active {
background: var(--accent-dim);
color: var(--accent);
}
.sidebar-playlists-loading {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
}
.sidebar-playlists-empty {
padding: var(--space-2);
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}