currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
+ >{currentTrack.artist}
currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
+ style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
+ onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}
diff --git a/src/i18n.ts b/src/i18n.ts
index 963d76e1..78164105 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -127,6 +127,7 @@ const enTranslation = {
artists: 'Artists',
albums: 'Albums',
songs: 'Songs',
+ enqueueAll: 'Add all to queue',
},
randomAlbums: {
title: 'Random Albums',
@@ -169,6 +170,11 @@ const enTranslation = {
minutes: 'min.',
track_one: '{{count}} Track',
track_other: '{{count}} Tracks',
+ filterPlaceholder: 'Filter playlists…',
+ colName: 'Name',
+ colTracks: 'Tracks',
+ colDuration: 'Duration',
+ noResults: 'No playlists match your filter.',
},
albums: {
title: 'All Albums',
@@ -371,10 +377,22 @@ const enTranslation = {
},
statistics: {
title: 'Statistics',
+ recentlyPlayed: 'Recently Played',
mostPlayed: 'Most Played Albums',
highestRated: 'Highest Rated Albums',
genreDistribution: 'Genre Distribution (Top 20)',
loadMore: 'Load more',
+ statArtists: 'Artists',
+ statAlbums: 'Albums',
+ statSongs: 'Songs',
+ statGenres: 'Genres',
+ genreSongs: '{{count}} Songs',
+ genreAlbums: '{{count}} Albums',
+ recentlyAdded: 'Recently Added',
+ decadeDistribution: 'Albums by Decade',
+ decadeAlbums_one: '{{count}} Album',
+ decadeAlbums_other: '{{count}} Albums',
+ decadeUnknown: 'Unknown',
},
player: {
regionLabel: 'Music Player',
@@ -526,6 +544,7 @@ const deTranslation = {
artists: 'Künstler',
albums: 'Alben',
songs: 'Songs',
+ enqueueAll: 'Alle in die Warteschlange',
},
randomAlbums: {
title: 'Zufallsalben',
@@ -568,6 +587,11 @@ const deTranslation = {
minutes: 'Min.',
track_one: '{{count}} Track',
track_other: '{{count}} Tracks',
+ filterPlaceholder: 'Playlists filtern…',
+ colName: 'Name',
+ colTracks: 'Tracks',
+ colDuration: 'Dauer',
+ noResults: 'Keine Playlists entsprechen dem Filter.',
},
albums: {
title: 'Alle Alben',
@@ -762,7 +786,7 @@ const deTranslation = {
delete: 'Löschen',
deleteConfirm: 'Playlist "{{name}}" löschen?',
clear: 'Leeren',
- shuffle: 'Queue mischen',
+ shuffle: 'Warteschlange mischen',
hide: 'Verbergen',
close: 'Schließen',
nextTracks: 'Nächste Titel',
@@ -770,10 +794,22 @@ const deTranslation = {
},
statistics: {
title: 'Statistiken',
+ recentlyPlayed: 'Zuletzt gehört',
mostPlayed: 'Meistgespielte Alben',
highestRated: 'Höchstbewertete Alben',
genreDistribution: 'Genre-Verteilung (Top 20)',
loadMore: 'Mehr laden',
+ statArtists: 'Künstler',
+ statAlbums: 'Alben',
+ statSongs: 'Songs',
+ statGenres: 'Genres',
+ genreSongs: '{{count}} Songs',
+ genreAlbums: '{{count}} Alben',
+ recentlyAdded: 'Neu hinzugefügt',
+ decadeDistribution: 'Alben nach Jahrzehnt',
+ decadeAlbums_one: '{{count}} Album',
+ decadeAlbums_other: '{{count}} Alben',
+ decadeUnknown: 'Unbekannt',
},
player: {
regionLabel: 'Musikplayer',
diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx
index ea0df5a7..95f59021 100644
--- a/src/pages/AlbumDetail.tsx
+++ b/src/pages/AlbumDetail.tsx
@@ -1,15 +1,14 @@
import React, { useEffect, useState } from 'react';
-import { useParams } from 'react-router-dom';
-import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
+import { useParams, useNavigate } from 'react-router-dom';
import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
-import { useNavigate } from 'react-router-dom';
-import { open } from '@tauri-apps/plugin-shell';
import { writeFile } from '@tauri-apps/plugin-fs';
import { join } from '@tauri-apps/api/path';
import AlbumCard from '../components/AlbumCard';
-import CachedImage, { useCachedUrl } from '../components/CachedImage';
+import AlbumHeader from '../components/AlbumHeader';
+import AlbumTrackList from '../components/AlbumTrackList';
+import { useCachedUrl } from '../components/CachedImage';
import { useTranslation } from 'react-i18next';
function sanitizeFilename(name: string): string {
@@ -20,78 +19,6 @@ function sanitizeFilename(name: string): string {
.substring(0, 200) || 'download';
}
-function formatDuration(seconds: number): string {
- const m = Math.floor(seconds / 60);
- const s = seconds % 60;
- return `${m}:${s.toString().padStart(2, '0')}`;
-}
-
-function formatSize(bytes?: number): string {
- if (!bytes) return '';
- return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
-}
-
-function codecLabel(song: { suffix?: string; bitRate?: number }): string {
- const parts: string[] = [];
- if (song.suffix) parts.push(song.suffix.toUpperCase());
- if (song.bitRate) parts.push(`${song.bitRate} kbps`);
- return parts.join(' · ');
-}
-
-/** Strip dangerous tags/attributes from server-provided HTML (e.g. artist bios from Last.fm) */
-function sanitizeHtml(html: string): string {
- const parser = new DOMParser();
- const doc = parser.parseFromString(html, 'text/html');
- doc.querySelectorAll('script, style, iframe, object, embed, form, input, button, select, base, meta, link').forEach(el => el.remove());
- doc.querySelectorAll('*').forEach(el => {
- Array.from(el.attributes).forEach(attr => {
- const name = attr.name.toLowerCase();
- const val = attr.value.toLowerCase().trim();
- if (name.startsWith('on') || (name === 'href' && (val.startsWith('javascript:') || val.startsWith('data:'))) || (name === 'src' && (val.startsWith('javascript:') || val.startsWith('data:')))) {
- el.removeAttribute(attr.name);
- }
- });
- });
- return doc.body.innerHTML;
-}
-
-function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
- const { t } = useTranslation();
- const [hover, setHover] = useState(0);
- return (
-
- {[1,2,3,4,5].map(n => (
- = n ? 'filled' : ''}`}
- onMouseEnter={() => setHover(n)}
- onMouseLeave={() => setHover(0)}
- onClick={() => onChange(n)}
- aria-label={`${n}`}
- role="radio"
- aria-checked={(hover || value) >= n}
- >
- ★
-
- ))}
-
- );
-}
-
-interface BioModalProps { bio: string; onClose: () => void; }
-function BioModal({ bio, onClose }: BioModalProps) {
- const { t } = useTranslation();
- return (
-
-
e.stopPropagation()}>
-
-
{t('albumDetail.bioModal')}
-
-
-
- );
-}
-
export default function AlbumDetail() {
const { t } = useTranslation();
const { id } = useParams<{ id: string }>();
@@ -102,6 +29,7 @@ export default function AlbumDetail() {
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
+
const [album, setAlbum] = useState
> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState([]);
const [ratings, setRatings] = useState>({});
@@ -137,8 +65,8 @@ export default function AlbumDetail() {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
- albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
- year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
+ albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
+ track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
if (tracks[0]) playTrack(tracks[0], tracks);
};
@@ -147,8 +75,8 @@ export default function AlbumDetail() {
if (!album) return;
const tracks = album.songs.map(s => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
- albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
- year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
+ albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
+ track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
enqueue(tracks);
};
@@ -157,8 +85,7 @@ export default function AlbumDetail() {
const track = {
id: song.id, title: song.title, artist: song.artist, album: song.album,
albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
- track: song.track, year: song.year, bitRate: song.bitRate,
- suffix: song.suffix, userRating: song.userRating
+ track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
};
playTrack(track, [track]);
};
@@ -176,7 +103,9 @@ export default function AlbumDetail() {
setBioOpen(true);
};
- const handleDownload = async (albumName: string, albumId: string) => {
+ const handleDownload = async () => {
+ if (!album) return;
+ const { name, id: albumId } = album.album;
setDownloadProgress(0);
try {
const url = buildDownloadUrl(albumId);
@@ -206,13 +135,13 @@ export default function AlbumDetail() {
const blob = new Blob(chunks);
if (auth.downloadFolder) {
const buffer = await blob.arrayBuffer();
- const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`);
+ const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`);
await writeFile(path, new Uint8Array(buffer));
} else {
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
- a.download = `${sanitizeFilename(albumName)}.zip`;
+ a.download = `${sanitizeFilename(name)}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@@ -222,33 +151,31 @@ export default function AlbumDetail() {
console.error('Download failed:', e);
setDownloadProgress(null);
} finally {
- // keep bar visible at 100% for 3 seconds so user sees completion
setTimeout(() => setDownloadProgress(null), 60000);
}
};
const toggleStar = async () => {
if (!album) return;
- const currentlyStarred = isStarred;
- setIsStarred(!currentlyStarred);
+ const wasStarred = isStarred;
+ setIsStarred(!wasStarred);
try {
- if (currentlyStarred) await unstar(album.album.id);
+ if (wasStarred) await unstar(album.album.id);
else await star(album.album.id);
} catch (e) {
console.error('Failed to toggle star', e);
- setIsStarred(currentlyStarred);
+ setIsStarred(wasStarred);
}
};
const toggleSongStar = async (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
- const currentlyStarred = starredSongs.has(song.id);
- const nextStarred = new Set(starredSongs);
- if (currentlyStarred) nextStarred.delete(song.id);
- else nextStarred.add(song.id);
- setStarredSongs(nextStarred);
+ const wasStarred = starredSongs.has(song.id);
+ const next = new Set(starredSongs);
+ if (wasStarred) next.delete(song.id); else next.add(song.id);
+ setStarredSongs(next);
try {
- if (currentlyStarred) await unstar(song.id, 'song');
+ if (wasStarred) await unstar(song.id, 'song');
else await star(song.id, 'song');
} catch (err) {
console.error('Failed to toggle song star', err);
@@ -265,234 +192,47 @@ export default function AlbumDetail() {
if (!album) return {t('albumDetail.notFound')}
;
const { album: info, songs } = album;
- const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const hasVariousArtists = songs.some(s => s.artist !== info.artist);
- const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
return (
- {bioOpen && bio &&
setBioOpen(false)} />}
+ setBioOpen(false)}
+ />
-
- {resolvedCoverUrl && (
-
- )}
-
-
-
-
navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
- {t('albumDetail.back')}
-
-
- {coverUrl ? (
-
- ) : (
-
♪
- )}
-
-
{t('common.album')}
-
{info.name}
-
- navigate(`/artist/${info.artistId}`)}
- >
- {info.artist}
-
-
-
- {info.year && {info.year} }
- {info.genre && · {info.genre} }
- · {songs.length} Tracks
- · {formatDuration(totalDuration)}
- {info.recordLabel && (
- <>
- ·
- navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
- >
- {info.recordLabel}
-
- >
- )}
-
-
-
-
- {t('albumDetail.playAll')}
-
-
- {t('albumDetail.enqueue')}
-
-
-
-
-
- {t('albumDetail.favorite')}
-
-
-
- {t('albumDetail.artistBio')}
-
- {downloadProgress !== null ? (
-
-
-
-
{downloadProgress}%
-
- ) : (
-
handleDownload(info.name, info.id)}>
- {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
-
- )}
-
-
- {t('albumDetail.downloadHintShort')}
-
-
-
-
-
-
-
-
-
-
#
-
{t('albumDetail.trackTitle')}
- {hasVariousArtists &&
{t('albumDetail.trackArtist')}
}
-
{t('albumDetail.trackFavorite')}
-
{t('albumDetail.trackRating')}
-
{t('albumDetail.trackDuration')}
-
{t('albumDetail.trackFormat')}
-
-
- {(() => {
- const discs = new Map
();
- songs.forEach(song => {
- const disc = song.discNumber ?? 1;
- if (!discs.has(disc)) discs.set(disc, []);
- discs.get(disc)!.push(song);
- });
- const discNums = Array.from(discs.keys()).sort((a, b) => a - b);
- const isMultiDisc = discNums.length > 1;
-
- return discNums.map(discNum => (
-
- {isMultiDisc && (
-
- 💿
- CD {discNum}
-
- )}
- {discs.get(discNum)!.map((song, i) => (
-
setHoveredSongId(song.id)}
- onMouseLeave={() => setHoveredSongId(null)}
- onDoubleClick={() => handlePlaySong(song)}
- onContextMenu={(e) => {
- e.preventDefault();
- const track = {
- id: song.id, title: song.title, artist: song.artist, album: song.album,
- albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
- year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
- };
- openContextMenu(e.clientX, e.clientY, track, 'album-song');
- }}
- role="row"
- draggable
- onDragStart={e => {
- e.dataTransfer.effectAllowed = 'copy';
- const track = {
- id: song.id, title: song.title, artist: song.artist, album: song.album,
- albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
- year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
- };
- e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
- }}
- >
-
handlePlaySong(song)}
- >
- {hoveredSongId === song.id && currentTrack?.id !== song.id
- ?
- : currentTrack?.id === song.id && isPlaying
- ?
- : currentTrack?.id === song.id
- ?
- : (song.track ?? i + 1)}
-
-
- {song.title}
-
- {hasVariousArtists && (
-
- {song.artist}
-
- )}
-
- toggleSongStar(song, e)}
- data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
- style={{ padding: '4px', height: 'auto', minHeight: 'unset', color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
- >
-
-
-
-
handleRate(song.id, r)}
- />
-
- {formatDuration(song.duration)}
-
-
- {(song.suffix || song.bitRate) && (
-
- {codecLabel(song)}
-
- )}
-
-
- ))}
-
- ));
- })()}
-
- {/* Total row */}
-
- {t('albumDetail.trackTotal')}
- {formatDuration(totalDuration)}
-
-
+
{relatedAlbums.length > 0 && (
-
-
-
{t('albumDetail.moreByArtist', { artist: info.artist })}
+
+
+
{t('albumDetail.moreByArtist', { artist: info.artist })}
{relatedAlbums.map(a =>
)}
diff --git a/src/pages/Favorites.tsx b/src/pages/Favorites.tsx
index 14006f2e..3f7b91e4 100644
--- a/src/pages/Favorites.tsx
+++ b/src/pages/Favorites.tsx
@@ -3,7 +3,8 @@ import AlbumRow from '../components/AlbumRow';
import ArtistRow from '../components/ArtistRow';
import { getStarred, SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
-import { Play } from 'lucide-react';
+import { Play, ListPlus } from 'lucide-react';
+import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
export default function Favorites() {
@@ -13,7 +14,9 @@ export default function Favorites() {
const [songs, setSongs] = useState
([]);
const [loading, setLoading] = useState(true);
- const { playTrack } = usePlayerStore();
+ const { playTrack, enqueue } = usePlayerStore();
+ const openContextMenu = usePlayerStore(s => s.openContextMenu);
+ const navigate = useNavigate();
useEffect(() => {
getStarred()
@@ -56,44 +59,68 @@ export default function Favorites() {
{songs.length > 0 && (
-
-
{t('favorites.songs')}
+
+
{t('favorites.songs')}
+ {
+ const tracks = songs.map(s => ({
+ id: s.id, title: s.title, artist: s.artist, album: s.album,
+ albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt,
+ track: s.track, year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
+ }));
+ enqueue(tracks);
+ }}
+ >
+
+ {t('favorites.enqueueAll')}
+
- {songs.map((song) => (
-
playTrack(song, songs)}
- role="row"
- draggable
- onDragStart={e => {
- e.dataTransfer.effectAllowed = 'copy';
- const track = {
- id: song.id, title: song.title, artist: song.artist, album: song.album,
- albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track,
- year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
- };
- e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
- }}
- >
-
{ e.stopPropagation(); playTrack(song, songs); }}
+
+
#
+
{t('albumDetail.trackTitle')}
+
{t('albumDetail.trackArtist')}
+
{t('albumDetail.trackDuration')}
+
+ {songs.map((song, i) => {
+ const track = {
+ id: song.id, title: song.title, artist: song.artist, album: song.album,
+ albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt,
+ track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating,
+ };
+ return (
+ playTrack(song, songs)}
+ onContextMenu={e => { e.preventDefault(); openContextMenu(e.clientX, e.clientY, track, 'song'); }}
+ role="row"
+ draggable
+ onDragStart={e => {
+ e.dataTransfer.effectAllowed = 'copy';
+ e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track }));
+ }}
>
-
-
-
-
{song.title}
-
{song.artist}
+
playTrack(song, songs)} style={{ cursor: 'pointer' }}>
+ {i + 1}
+
+
+ {song.title}
+
+
+ song.artistId && navigate(`/artist/${song.artistId}`)}
+ >{song.artist}
+
+
+ {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
+
-
- {Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
-
-
- ))}
+ );
+ })}
)}
diff --git a/src/pages/Playlists.tsx b/src/pages/Playlists.tsx
index 1de433b2..393d7707 100644
--- a/src/pages/Playlists.tsx
+++ b/src/pages/Playlists.tsx
@@ -1,13 +1,43 @@
-import React, { useEffect, useState } from 'react';
+import React, { useEffect, useState, useMemo } from 'react';
import { SubsonicPlaylist, getPlaylists, getPlaylist, deletePlaylist } from '../api/subsonic';
import { usePlayerStore } from '../store/playerStore';
-import { ListMusic, Play, Trash2 } from 'lucide-react';
+import { Play, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
import { useTranslation } from 'react-i18next';
+type SortKey = 'name' | 'songCount' | 'duration';
+type SortDir = 'asc' | 'desc';
+
+function formatDuration(seconds: number): string {
+ const h = Math.floor(seconds / 3600);
+ const m = Math.floor((seconds % 3600) / 60);
+ return h > 0 ? `${h}h ${m}m` : `${m}m`;
+}
+
+function SortHeader({
+ label, sortKey, current, dir, onSort
+}: {
+ label: string;
+ sortKey: SortKey;
+ current: SortKey;
+ dir: SortDir;
+ onSort: (k: SortKey) => void;
+}) {
+ const active = current === sortKey;
+ return (
+
onSort(sortKey)}>
+ {label}
+ {active ? (dir === 'asc' ? : ) : null}
+
+ );
+}
+
export default function Playlists() {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState
([]);
const [loading, setLoading] = useState(true);
+ const [filter, setFilter] = useState('');
+ const [sortKey, setSortKey] = useState('name');
+ const [sortDir, setSortDir] = useState('asc');
const playTrack = usePlayerStore(s => s.playTrack);
const clearQueue = usePlayerStore(s => s.clearQueue);
@@ -15,104 +45,93 @@ export default function Playlists() {
const fetchPlaylists = () => {
setLoading(true);
getPlaylists()
- .then(data => {
- setPlaylists(data);
- setLoading(false);
- })
- .catch(err => {
- console.error('Failed to load playlists', err);
- setLoading(false);
- });
+ .then(data => { setPlaylists(data); setLoading(false); })
+ .catch(err => { console.error('Failed to load playlists', err); setLoading(false); });
};
- useEffect(() => {
- fetchPlaylists();
- }, []);
+ useEffect(() => { fetchPlaylists(); }, []);
+
+ const handleSort = (key: SortKey) => {
+ if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
+ else { setSortKey(key); setSortDir('asc'); }
+ };
const handlePlay = async (id: string) => {
try {
const data = await getPlaylist(id);
const tracks = data.songs.map((s: any) => ({
id: s.id, title: s.title, artist: s.artist, album: s.album,
- albumId: s.albumId, artistId: s.artistId, duration: s.duration, coverArt: s.coverArt, track: s.track,
- year: s.year, bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
+ albumId: s.albumId, artistId: s.artistId, duration: s.duration,
+ coverArt: s.coverArt, track: s.track, year: s.year,
+ bitRate: s.bitRate, suffix: s.suffix, userRating: s.userRating,
}));
- if (tracks.length > 0) {
- clearQueue();
- playTrack(tracks[0], tracks);
- }
- } catch (e) {
- console.error('Failed to play playlist', e);
- }
+ if (tracks.length > 0) { clearQueue(); playTrack(tracks[0], tracks); }
+ } catch (e) { console.error('Failed to play playlist', e); }
};
const handleDelete = async (id: string, name: string) => {
if (confirm(t('playlists.confirmDelete', { name }))) {
- try {
- await deletePlaylist(id);
- fetchPlaylists();
- } catch (e) {
- console.error('Failed to delete playlist', e);
- }
+ try { await deletePlaylist(id); fetchPlaylists(); }
+ catch (e) { console.error('Failed to delete playlist', e); }
}
};
+ const visible = useMemo(() => {
+ const q = filter.toLowerCase();
+ const filtered = q ? playlists.filter(p => p.name.toLowerCase().includes(q)) : playlists;
+ return [...filtered].sort((a, b) => {
+ let cmp = 0;
+ if (sortKey === 'name') cmp = a.name.localeCompare(b.name);
+ else if (sortKey === 'songCount') cmp = a.songCount - b.songCount;
+ else cmp = a.duration - b.duration;
+ return sortDir === 'asc' ? cmp : -cmp;
+ });
+ }, [playlists, filter, sortKey, sortDir]);
+
return (
-
-
-
{t('playlists.title')}
+
+
{t('playlists.title')}
+ setFilter(e.target.value)}
+ />
{loading ? (
-
{t('playlists.loading')}
+
) : playlists.length === 0 ? (
-
- {t('playlists.empty')}
-
+
{t('playlists.empty')}
) : (
-
- {playlists.map(p => (
-
-
-
- {p.name}
-
-
- {t('playlists.track', { count: p.songCount })} • {Math.floor(p.duration / 60)} {t('playlists.minutes')}
-
-
+
+
-
-
handlePlay(p.id)}
- style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
- >
- {t('playlists.play')}
-
-
handleDelete(p.id, p.name)}
- data-tooltip={t('playlists.deleteTooltip')}
- style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
- >
-
-
-
+ {visible.length === 0 ? (
+
{t('playlists.noResults')}
+ ) : visible.map(p => (
+
+
handlePlay(p.id)} data-tooltip={t('playlists.play')}>
+
+
+
{p.name}
+
{t('playlists.track', { count: p.songCount })}
+
{formatDuration(p.duration)}
+
handleDelete(p.id, p.name)}
+ data-tooltip={t('playlists.deleteTooltip')}
+ >
+
+
))}
diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx
index a69a2ec4..a60c476a 100644
--- a/src/pages/RandomAlbums.tsx
+++ b/src/pages/RandomAlbums.tsx
@@ -11,38 +11,38 @@ export default function RandomAlbums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState
([]);
const [loading, setLoading] = useState(true);
- const [renderKey, setRenderKey] = useState(0);
const [progress, setProgress] = useState(0);
const timerRef = useRef | null>(null);
const progressRef = useRef | null>(null);
+ const loadingRef = useRef(false);
+
+ const clearTimers = () => {
+ if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
+ if (progressRef.current) { clearInterval(progressRef.current); progressRef.current = null; }
+ };
const load = useCallback(async () => {
+ if (loadingRef.current) return;
+ loadingRef.current = true;
setLoading(true);
try {
const data = await getAlbumList('random', ALBUM_COUNT);
setAlbums(data);
- setRenderKey(k => k + 1);
} catch (e) {
console.error(e);
} finally {
+ loadingRef.current = false;
setLoading(false);
}
}, []);
const startCycle = useCallback(() => {
- // Clear existing timers
- if (timerRef.current) clearInterval(timerRef.current);
- if (progressRef.current) clearInterval(progressRef.current);
-
- // Reset progress bar
+ clearTimers();
setProgress(0);
const startTime = Date.now();
progressRef.current = setInterval(() => {
- const elapsed = Date.now() - startTime;
- setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
+ setProgress(Math.min((Date.now() - startTime) / INTERVAL_MS * 100, 100));
}, 100);
-
- // Auto-refresh
timerRef.current = setInterval(() => {
load().then(() => startCycle());
}, INTERVAL_MS);
@@ -50,13 +50,11 @@ export default function RandomAlbums() {
useEffect(() => {
load().then(() => startCycle());
- return () => {
- if (timerRef.current) clearInterval(timerRef.current);
- if (progressRef.current) clearInterval(progressRef.current);
- };
+ return clearTimers;
}, [load, startCycle]);
const handleManualRefresh = () => {
+ clearTimers();
load().then(() => startCycle());
};
@@ -85,7 +83,7 @@ export default function RandomAlbums() {
) : (
-
+
)}
diff --git a/src/pages/RandomMix.tsx b/src/pages/RandomMix.tsx
index 2b97ad94..a9ef82f2 100644
--- a/src/pages/RandomMix.tsx
+++ b/src/pages/RandomMix.tsx
@@ -42,6 +42,9 @@ export default function RandomMix() {
const [songs, setSongs] = useState
([]);
const [loading, setLoading] = useState(true);
const playTrack = usePlayerStore(s => s.playTrack);
+ const openContextMenu = usePlayerStore(s => s.openContextMenu);
+ const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
+ const [contextMenuSongId, setContextMenuSongId] = useState(null);
const [starredSongs, setStarredSongs] = useState>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState(null);
@@ -69,6 +72,10 @@ export default function RandomMix() {
.catch(() => setLoading(false));
};
+ useEffect(() => {
+ if (!contextMenuOpen) setContextMenuSongId(null);
+ }, [contextMenuOpen]);
+
useEffect(() => {
fetchSongs();
getGenres().then(setServerGenres).catch(() => {});
@@ -314,8 +321,9 @@ export default function RandomMix() {
{t('randomMix.trackDuration')}
{genreMixSongs.map(song => (
-
playTrack(song, genreMixSongs)} role="row" draggable
+ onContextMenu={e => { e.preventDefault(); setContextMenuSongId(song.id); openContextMenu(e.clientX, e.clientY, { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating }, 'song'); }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating } }));
@@ -357,11 +365,17 @@ export default function RandomMix() {
{filteredSongs.map((song) => (
playTrack(song, filteredSongs)}
role="row"
draggable
+ onContextMenu={e => {
+ e.preventDefault();
+ const track = { id: song.id, title: song.title, artist: song.artist, album: song.album, albumId: song.albumId, artistId: song.artistId, duration: song.duration, coverArt: song.coverArt, track: song.track, year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating };
+ setContextMenuSongId(song.id);
+ openContextMenu(e.clientX, e.clientY, track, 'song');
+ }}
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
const track = {
diff --git a/src/pages/Statistics.tsx b/src/pages/Statistics.tsx
index 5a981b22..a8ac5ec9 100644
--- a/src/pages/Statistics.tsx
+++ b/src/pages/Statistics.tsx
@@ -1,26 +1,30 @@
import React, { useEffect, useState } from 'react';
-import { getAlbumList, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
+import { getAlbumList, getArtists, getGenres, SubsonicAlbum, SubsonicGenre } from '../api/subsonic';
import AlbumRow from '../components/AlbumRow';
-import { BarChart3 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export default function Statistics() {
const { t } = useTranslation();
+ const [recent, setRecent] = useState
([]);
const [frequent, setFrequent] = useState([]);
const [highest, setHighest] = useState([]);
const [genres, setGenres] = useState([]);
+ const [artistCount, setArtistCount] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
+ getAlbumList('recent', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
getAlbumList('highest', 12).catch(() => []),
- getGenres().catch(() => [])
- ]).then(([f, h, g]) => {
- setFrequent(f);
- setHighest(h);
- // Sort genres by album count or song count
- setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20)); // Top 20 genres
+ getGenres().catch(() => []),
+ getArtists().catch(() => []),
+ ]).then(([rc, fr, hi, g, a]) => {
+ setRecent(rc);
+ setFrequent(fr);
+ setHighest(hi);
+ setGenres(g.sort((a, b) => b.songCount - a.songCount).slice(0, 20));
+ setArtistCount(a.length);
setLoading(false);
}).catch(() => setLoading(false));
}, []);
@@ -33,29 +37,44 @@ export default function Statistics() {
try {
const more = await getAlbumList(type, 12, currentList.length);
const newItems = more.filter(m => !currentList.find(c => c.id === m.id));
- if (newItems.length > 0) {
- setter(prev => [...prev, ...newItems]);
- }
+ if (newItems.length > 0) setter(prev => [...prev, ...newItems]);
} catch (e) {
console.error('Failed to load more', e);
}
};
+ const totalSongs = genres.reduce((acc, g) => acc + g.songCount, 0);
+ const totalAlbums = genres.reduce((acc, g) => acc + g.albumCount, 0);
const maxGenreCount = Math.max(...genres.map(g => g.songCount), 1);
+ const stats = [
+ { label: t('statistics.statArtists'), value: artistCount },
+ { label: t('statistics.statAlbums'), value: totalAlbums || null },
+ { label: t('statistics.statSongs'), value: totalSongs || null },
+ { label: t('statistics.statGenres'), value: genres.length || null },
+ ];
+
return (
-
-
-
{t('statistics.title')}
-
+
{t('statistics.title')}
{loading ? (
-
+
) : (
-
+
+
+
+ {stats.map(s => (
+
+ {s.value?.toLocaleString() ?? '—'}
+ {s.label}
+
+ ))}
+
+
+ {recent.length > 0 && (
+
+ )}
{genres.length > 0 && (
-
-
-
{t('statistics.genreDistribution')}
-
-
-
- {genres.map(genre => {
- const percentage = (genre.songCount / maxGenreCount) * 100;
- return (
-
-
- {genre.value}
- {genre.songCount} Songs
-
-
+
+ {t('statistics.genreDistribution')}
+
+ {genres.map(genre => (
+
+
+ {genre.value}
+
+ {t('statistics.genreSongs', { count: genre.songCount })}
+ {' · '}
+ {t('statistics.genreAlbums', { count: genre.albumCount })}
+
- );
- })}
+
+
+ ))}
-
+
)}
diff --git a/src/styles/components.css b/src/styles/components.css
index 482825ff..abb04b4f 100644
--- a/src/styles/components.css
+++ b/src/styles/components.css
@@ -185,17 +185,12 @@
.artist-card {
display: flex;
flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: var(--space-4);
background: var(--bg-card);
border-radius: var(--radius-lg);
border: 1px solid var(--border-subtle);
cursor: pointer;
- transition: all var(--transition-base);
- text-align: center;
- gap: var(--space-3);
- height: 100%; /* fill grid row */
+ overflow: hidden;
+ transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base);
}
.artist-card:hover {
transform: translateY(-3px);
@@ -203,38 +198,49 @@
border-color: var(--border);
}
.artist-card-avatar {
- width: 100px;
- height: 100px;
- border-radius: 50%;
+ position: relative;
+ aspect-ratio: 1;
+ overflow: hidden;
background: var(--bg-hover);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
- box-shadow: var(--shadow-sm);
}
+.artist-card-avatar img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ transition: transform var(--transition-slow);
+}
+.artist-card:hover .artist-card-avatar img { transform: scale(1.04); }
.artist-card-avatar-initial {
- background: var(--bg-card);
- border: 2px solid;
- box-shadow: none;
+ aspect-ratio: 1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
overflow: hidden;
}
.artist-card-avatar-initial span {
- font-size: 2.5rem;
+ font-size: 3rem;
font-weight: 800;
font-family: var(--font-display);
line-height: 1;
user-select: none;
}
+.artist-card-info {
+ padding: var(--space-3) var(--space-3) var(--space-2);
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
.artist-card-name {
font-weight: 600;
- font-size: 14px;
+ font-size: 13px;
color: var(--text-primary);
- display: -webkit-box;
- -webkit-line-clamp: 2;
- line-clamp: 2;
- -webkit-box-orient: vertical;
overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
.artist-card-meta {
font-size: 12px;
@@ -324,7 +330,8 @@
.album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
}
-.album-grid .album-card {
+.album-grid .album-card,
+.album-grid .artist-card {
flex: 0 0 clamp(140px, 15vw, 180px);
scroll-snap-align: start;
}
@@ -519,6 +526,14 @@
}
.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); }
.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); }
+.album-detail-actions-primary { display: flex; gap: 8px; flex-wrap: wrap; }
+.album-detail-content { position: relative; z-index: 1; }
+.album-detail-badge { margin-bottom: 0.5rem; }
+.album-detail-back { margin-bottom: 1rem; gap: 6px; }
+.album-info-dot { margin: 0 4px; }
+.album-related { padding: 0 var(--space-6) var(--space-8); }
+.album-related-divider { border-top: 1px solid var(--border-subtle); margin-bottom: 2rem; }
+.album-related-title { margin-bottom: 1rem; }
.download-hint {
display: flex;
@@ -568,6 +583,8 @@
/* ─ Tracklist ─ */
.tracklist { padding: 0 var(--space-6) var(--space-6); }
+.col-center { text-align: center; }
+
.tracklist-header {
display: grid;
grid-template-columns: 36px minmax(100px, 3fr) 70px 80px 60px 120px;
@@ -630,7 +647,8 @@
}
.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; }
.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; }
-.track-row:hover { background: var(--bg-hover); }
+.track-row:hover,
+.track-row.context-active { background: var(--bg-hover); }
.track-row.active { background: var(--accent-dim); animation: track-pulse 3s ease-in-out infinite; }
.track-row.active:hover { background: var(--accent-dim); animation: none; }
@keyframes track-pulse {
@@ -706,7 +724,11 @@
text-overflow: ellipsis;
}
.track-size { color: var(--ctp-overlay0); }
-.track-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+.track-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-align: center; }
+.track-meta { display: flex; align-items: center; }
+.track-meta .track-codec { margin-top: 0; }
+.track-star-cell { display: flex; justify-content: center; }
+.track-star-btn { padding: 4px; height: auto; min-height: unset; }
/* ─ Modal ─ */
.modal-overlay {
@@ -736,6 +758,7 @@
}
.modal-close { position: absolute; top: var(--space-4); right: var(--space-4); color: var(--text-muted); }
.modal-close:hover { color: var(--text-primary); }
+.modal-title { margin-bottom: 1rem; font-family: var(--font-display); }
.artist-bio { font-size: 14px; line-height: 1.7; color: var(--text-secondary); }
.artist-bio a { color: var(--accent); text-decoration: underline; }
@@ -1467,3 +1490,196 @@
bottom: auto;
}
+
+/* ─ Playlists Page ─ */
+.playlist-page-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+.playlist-page-header .page-title { margin-bottom: 0; }
+
+.playlist-filter-input {
+ background: var(--bg-surface);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-md);
+ color: var(--text-primary);
+ font-size: 13px;
+ padding: 6px 12px;
+ width: 220px;
+ outline: none;
+ transition: border-color 0.15s;
+}
+.playlist-filter-input:focus { border-color: var(--accent); }
+.playlist-filter-input::placeholder { color: var(--text-muted); }
+
+.playlist-list { display: flex; flex-direction: column; }
+
+.playlist-list-header {
+ display: grid;
+ grid-template-columns: 36px 1fr 90px 90px 44px;
+ gap: var(--space-3);
+ padding: 6px var(--space-3);
+ border-bottom: 1px solid var(--border-subtle);
+ margin-bottom: 4px;
+}
+
+.playlist-sort-btn {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font-size: 11px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ transition: color 0.15s;
+}
+.playlist-sort-btn:hover,
+.playlist-sort-btn.active { color: var(--accent); }
+
+.playlist-row {
+ display: grid;
+ grid-template-columns: 36px 1fr 90px 90px 44px;
+ gap: var(--space-3);
+ align-items: center;
+ padding: 6px var(--space-3);
+ border-radius: var(--radius-md);
+ transition: background 0.15s;
+}
+.playlist-row:hover { background: var(--bg-hover); }
+
+.playlist-play-icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 50%;
+ background: var(--accent);
+ color: var(--ctp-base);
+ border: none;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s, transform 0.15s;
+ flex-shrink: 0;
+}
+.playlist-row:hover .playlist-play-icon { opacity: 1; }
+.playlist-play-icon:hover { transform: scale(1.1); }
+
+.playlist-name {
+ font-size: 14px;
+ font-weight: 500;
+ color: var(--text-primary);
+}
+
+.playlist-meta {
+ font-size: 12px;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.playlist-delete-btn {
+ opacity: 0;
+ color: var(--ctp-red) !important;
+ padding: 4px;
+ height: auto;
+ min-height: unset;
+ transition: opacity 0.15s;
+ justify-self: center;
+}
+.playlist-row:hover .playlist-delete-btn { opacity: 1; }
+
+/* ─ Statistics Page ─ */
+.stats-page {
+ display: flex;
+ flex-direction: column;
+ gap: 3rem;
+}
+
+.stats-overview {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: var(--space-4);
+}
+@media (max-width: 600px) {
+ .stats-overview { grid-template-columns: repeat(2, 1fr); }
+}
+
+.stats-card {
+ background: var(--bg-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5) var(--space-4);
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ align-items: center;
+ text-align: center;
+}
+.stats-card-value {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 800;
+ color: var(--accent);
+ line-height: 1;
+}
+.stats-card-label {
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+/* Genre chart */
+.genre-chart {
+ background: var(--bg-card);
+ border: 1px solid var(--border-subtle);
+ border-radius: var(--radius-lg);
+ padding: var(--space-5);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-4);
+}
+.genre-row { display: flex; flex-direction: column; gap: 6px; }
+.genre-row-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: var(--space-3);
+}
+.genre-name {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-primary);
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.genre-counts {
+ font-size: 12px;
+ color: var(--text-muted);
+ white-space: nowrap;
+ flex-shrink: 0;
+}
+.genre-bar-track {
+ width: 100%;
+ height: 6px;
+ background: var(--bg-hover);
+ border-radius: 3px;
+ overflow: hidden;
+}
+.genre-bar-fill {
+ height: 100%;
+ background: var(--accent);
+ border-radius: 3px;
+ transition: width 0.8s ease-out;
+}