fixed bugs

This commit is contained in:
kveld9
2026-04-12 17:37:36 -03:00
parent bef6941a2b
commit 94323e91fa
14 changed files with 424 additions and 123 deletions
+29 -24
View File
@@ -1,11 +1,11 @@
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles, Star, Trash2 } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { useShallow } from 'zustand/react/shallow';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getArtist, getPlaylists, getPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -40,17 +40,18 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
const { t } = useTranslation();
const subRef = useRef<HTMLDivElement>(null);
const newNameRef = useRef<HTMLInputElement>(null);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [adding, setAdding] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [newName, setNewName] = useState('');
const [flipLeft, setFlipLeft] = useState(false);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
const storePlaylists = usePlaylistStore((s) => s.playlists);
const recentIds = usePlaylistStore((s) => s.recentIds);
const createPlaylist = usePlaylistStore((s) => s.createPlaylist);
const touchPlaylist = usePlaylistStore((s) => s.touchPlaylist);
useEffect(() => {
getPlaylists().then((all) => {
const sorted = [...all].sort((a, b) => {
// Sort playlists by recent usage
const playlists = useMemo(() => {
return [...storePlaylists].sort((a, b) => {
const ai = recentIds.indexOf(a.id);
const bi = recentIds.indexOf(b.id);
if (ai === -1 && bi === -1) return a.name.localeCompare(b.name);
@@ -58,10 +59,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
if (bi === -1) return -1;
return ai - bi;
});
setPlaylists(sorted);
}).catch(() => {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [storePlaylists, recentIds]);
// Flip submenu left if it would overflow the right edge of the viewport
useLayoutEffect(() => {
@@ -92,10 +90,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
const handleCreate = async () => {
const name = newName.trim() || t('playlists.unnamed');
try {
const pl = await createPlaylist(name, songIds);
if (pl?.id) touchPlaylist(pl.id);
} catch {}
await createPlaylist(name, songIds);
onDone();
};
@@ -139,7 +134,7 @@ export function AddToPlaylistSubmenu({ songIds, onDone, dropDown, triggerId }: {
{playlists.length === 0 && (
<div className="context-submenu-empty">{t('playlists.empty')}</div>
)}
{playlists.map((pl) => (
{playlists.map((pl: SubsonicPlaylist) => (
<div
key={pl.id}
className="context-menu-item"
@@ -990,6 +985,10 @@ export default function ContextMenu() {
previousFocusRef.current = document.activeElement as HTMLElement | null;
return;
}
// Clean up any keyboard focus styling when menu closes
menuRef.current
?.querySelectorAll<HTMLElement>('.context-menu-keyboard-active')
.forEach(el => el.classList.remove('context-menu-keyboard-active'));
const prev = previousFocusRef.current;
previousFocusRef.current = null;
if (prev?.isConnected) {
@@ -1572,12 +1571,15 @@ export default function ContextMenu() {
const { showToast } = await import('../utils/toast');
const { deletePlaylist } = await import('../api/subsonic');
const { usePlaylistStore } = await import('../store/playlistStore');
const removeId = usePlaylistStore.getState().removeId;
const { removeId } = usePlaylistStore.getState();
try {
await deletePlaylist(playlist.id);
removeId(playlist.id);
// Update local playlist state without page reload to preserve audio playback state
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => p.id !== playlist.id),
}));
showToast(t('playlists.deleteSuccess', { count: 1 }), 3000, 'info');
window.location.reload();
} catch {
showToast(t('playlists.deleteFailed', { name: playlist.name }), 3000, 'error');
}
@@ -1713,21 +1715,24 @@ export default function ContextMenu() {
const { showToast } = await import('../utils/toast');
const { usePlaylistStore } = await import('../store/playlistStore');
const { deletePlaylist } = await import('../api/subsonic');
const removeId = usePlaylistStore.getState().removeId;
let deleted = 0;
const { removeId } = usePlaylistStore.getState();
const deletedIds: string[] = [];
for (const pl of selectedPlaylists) {
try {
await deletePlaylist(pl.id);
removeId(pl.id);
deleted++;
deletedIds.push(pl.id);
} catch {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
if (deletedIds.length > 0) {
// Update local playlist state without page reload to preserve audio playback state
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !deletedIds.includes(p.id)),
}));
showToast(t('playlists.deleteSuccess', { count: deletedIds.length }), 3000, 'info');
}
window.location.reload();
})}>
<Trash2 size={14} /> {t('playlists.deleteSelected')}
</div>
+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);
+13 -11
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';
@@ -14,7 +14,8 @@ import {
} from 'lucide-react';
import PsysonicLogo from './PsysonicLogo';
import PSmallLogo from './PSmallLogo';
import { getPlaylists, SubsonicPlaylist } from '../api/subsonic';
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.
@@ -58,8 +59,13 @@ export default function Sidebar({
const sidebarItems = useSidebarStore(s => s.items);
const [libraryDropdownOpen, setLibraryDropdownOpen] = useState(false);
const [playlistsExpanded, setPlaylistsExpanded] = useState(false);
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [playlistsLoading, setPlaylistsLoading] = 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;
@@ -120,12 +126,8 @@ export default function Sidebar({
// Fetch playlists when expanded
useEffect(() => {
if (!playlistsExpanded || !isLoggedIn) return;
setPlaylistsLoading(true);
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
}, [playlistsExpanded, isLoggedIn]);
fetchPlaylists();
}, [playlistsExpanded, isLoggedIn, fetchPlaylists]);
// Resolve ordered, visible items per section from store config
const visibleLibrary = sidebarItems
@@ -264,7 +266,7 @@ export default function Sidebar({
) : playlists.length === 0 ? (
<div className="sidebar-playlists-empty">{t('playlists.empty')}</div>
) : (
playlists.map(pl => (
playlists.map((pl: { id: string; name: string }) => (
<NavLink
key={pl.id}
to={`/playlists/${pl.id}`}
+32
View File
@@ -110,6 +110,9 @@ export const deTranslation = {
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 +299,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 +929,30 @@ 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',
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',
},
mostPlayed: {
title: 'Meistgehört',
+32
View File
@@ -111,6 +111,9 @@ export const enTranslation = {
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 +300,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 +931,30 @@ 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',
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',
},
mostPlayed: {
title: 'Most Played',
+17 -3
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',
@@ -207,6 +208,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',
@@ -279,7 +287,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',
addToPlaylist: 'Agregar a Lista de Reproducción',
},
artists: {
title: 'Artistas',
@@ -294,8 +302,8 @@ export const esTranslation = {
albumCount_one: '{{count}} Álbum',
albumCount_other: '{{count}} Álbumes',
selectionCount: '{{count}} seleccionados',
select: 'Seleccionar',
startSelect: 'Activar selección',
select: 'Selección múltiple',
startSelect: 'Activar selección múltiple',
cancelSelect: 'Cancelar',
addToPlaylist: 'Agregar a Lista',
},
@@ -589,6 +597,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',
@@ -618,6 +627,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',
@@ -937,6 +950,7 @@ export const esTranslation = {
select: 'Seleccionar',
startSelect: 'Activar selección',
cancelSelect: 'Cancelar',
loadingArtists: 'Resolviendo {{count}} artistas…',
myPlaylists: 'Mis Listas',
noOtherPlaylists: 'No hay otras listas disponibles',
addToPlaylistSuccess: '{{count}} canciones agregadas a {{playlist}}',
+46 -11
View File
@@ -110,6 +110,9 @@ export const frTranslation = {
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 +275,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 +299,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 +927,30 @@ 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',
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',
},
mostPlayed: {
title: 'Les plus joués',
@@ -929,6 +961,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',
+46 -11
View File
@@ -110,6 +110,9 @@ export const nbTranslation = {
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 +275,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 +299,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 +926,30 @@ 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',
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',
},
mostPlayed: {
title: 'Mest spilt',
@@ -928,6 +960,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',
+46 -11
View File
@@ -110,6 +110,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 +275,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 +299,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 +927,30 @@ 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',
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 +961,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',
+47 -12
View File
@@ -111,6 +111,9 @@ export const ruTranslation = {
goToArtist: 'К исполнителю',
download: 'Скачать (ZIP)',
addToPlaylist: 'В плейлист',
selectedPlaylists: '{{count}} плейлистов выбрано',
selectedAlbums: '{{count}} альбомов выбрано',
selectedArtists: '{{count}} исполнителей выбрано',
songInfo: 'Сведения о треке',
},
albumDetail: {
@@ -281,17 +284,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 +310,11 @@ export const ruTranslation = {
albumCount_few: '{{count}} альбома',
albumCount_many: '{{count}} альбомов',
albumCount_other: '{{count}} альбомов',
selectionCount: '{{count}} выбрано',
select: 'Множественный выбор',
startSelect: 'Включить множественный выбор',
cancelSelect: 'Отмена',
addToPlaylist: 'В плейлист',
},
login: {
subtitle: 'Десктопный клиент для Navidrome',
@@ -978,6 +986,30 @@ 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: 'Отмена',
loadingArtists: 'Загрузка {{count}} исполнителей…',
myPlaylists: 'Мои плейлисты',
noOtherPlaylists: 'Нет других доступных плейлистов',
addToPlaylistSuccess: '{{count}} треков добавлено в {{playlist}}',
addToPlaylistNoNew: 'Нет новых треков для {{playlist}}',
addToPlaylistError: 'Ошибка при добавлении в плейлист',
},
mostPlayed: {
title: 'Популярное',
@@ -987,7 +1019,10 @@ export const ruTranslation = {
sortMost: 'Сначала популярные',
sortLeast: 'Сначала малоизвестные',
loadMore: 'Загрузить больше альбомов',
noData: 'Нет данных о прослушиваниях. Начните слушать!',
noData: 'Пока нет данных о прослушивании. Начните слушать!',
noArtists: 'Все исполнители отфильтрованы.',
filterCompilations: 'Скрыть исполнителей сборников (Various Artists, Soundtracks и др.)',
filterCompilationsShort: 'Скрыть сборники',
},
radio: {
title: 'Онлайн-радио',
+47 -12
View File
@@ -110,6 +110,9 @@ export const zhTranslation = {
goToArtist: '前往艺术家',
download: '下载 (ZIP)',
addToPlaylist: '添加到播放列表',
selectedPlaylists: '已选择 {{count}} 个播放列表',
selectedAlbums: '已选择 {{count}} 个专辑',
selectedArtists: '已选择 {{count}} 个艺术家',
songInfo: '歌曲信息',
},
albumDetail: {
@@ -272,17 +275,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 +299,11 @@ export const zhTranslation = {
notFound: '未找到艺术家。',
albumCount_one: '{{count}} 张专辑',
albumCount_other: '{{count}} 张专辑',
selectionCount: '{{count}} 已选择',
select: '多选',
startSelect: '启用多选',
cancelSelect: '取消',
addToPlaylist: '添加到播放列表',
},
login: {
subtitle: '您的 Navidrome 桌面播放器',
@@ -915,6 +923,30 @@ 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: '取消',
loadingArtists: '正在解析 {{count}} 位艺术家…',
myPlaylists: '我的播放列表',
noOtherPlaylists: '没有其他可用播放列表',
addToPlaylistSuccess: '{{count}} 首歌曲已添加到 {{playlist}}',
addToPlaylistNoNew: '{{playlist}} 没有新歌曲',
addToPlaylistError: '添加到播放列表时出错',
},
mostPlayed: {
title: '最常播放',
@@ -924,7 +956,10 @@ export const zhTranslation = {
sortMost: '最多播放在前',
sortLeast: '最少播放在前',
loadMore: '加载更多专辑',
noData: '暂无播放数据开始听吧!',
noData: '暂无播放数据开始听吧!',
noArtists: '所有艺术家已过滤。',
filterCompilations: '隐藏合辑艺术家(Various Artists、原声带等)',
filterCompilationsShort: '隐藏合辑',
},
radio: {
title: '网络电台',
+20 -14
View File
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { ListMusic, Play, Plus, Trash2, X, CheckSquare2, Check } from 'lucide-react';
import { getPlaylists, createPlaylist, deletePlaylist, SubsonicPlaylist, getPlaylist, buildCoverArtUrl, coverArtCacheKey, updatePlaylist } from '../api/subsonic';
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';
@@ -20,8 +20,10 @@ export default function Playlists() {
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('');
@@ -54,23 +56,20 @@ export default function Playlists() {
const selectedPlaylists = playlists.filter(p => selectedIds.has(p.id));
useEffect(() => {
getPlaylists()
.then(setPlaylists)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
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 {}
// Refresh playlists from API to get the new one
await fetchPlaylists();
setCreating(false);
setNewName('');
};
@@ -99,8 +98,13 @@ 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);
};
@@ -116,7 +120,9 @@ export default function Playlists() {
showToast(t('playlists.deleteFailed', { name: pl.name }), 3000, 'error');
}
}
setPlaylists((prev) => prev.filter((p) => !selectedIds.has(p.id)));
usePlaylistStore.setState((s) => ({
playlists: s.playlists.filter((p) => !selectedIds.has(p.id)),
}));
clearSelection();
if (deleted > 0) {
showToast(t('playlists.deleteSuccess', { count: deleted }), 3000, 'info');
+35 -1
View File
@@ -1,22 +1,56 @@
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;
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,
touchPlaylist: (id) =>
set((s) => ({
recentIds: [id, ...s.recentIds.filter((x) => x !== id)].slice(0, 50),
})),
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' }
)
+1
View File
@@ -3713,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 {