feat: statistics upgrade, playlists redesign, artist cards, and UX improvements (v1.4.0)

- Statistics page: library stat cards, recently played, most played, highest rated, genre chart
- Playlists page: list layout with sort (Name/Tracks/Duration) and filter input
- Favorites songs: full tracklist layout with artist column, context menu, enqueue-all button
- AlbumDetail: extracted AlbumHeader and AlbumTrackList components
- Artist cards: square cover, same sizing as album cards (clamp 140-180px)
- Random Albums: removed renderKey remount, added loadingRef guard, fixed manual refresh race
- Context menu: "Go to Album" option for song and queue-item types
- Queue panel meta box: artist → artist page, album → album page, removed year
- Random Mix: hover persistence via .context-active class while context menu is open
- i18n: "Warteschlange" consistently used for queue in German

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-16 17:36:58 +01:00
parent f666f84479
commit d3ffa30bf5
18 changed files with 1073 additions and 551 deletions
+54
View File
@@ -5,6 +5,60 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.4.0] - 2026-03-16
### Added
#### Statistics Page — Upgraded
- **Library overview**: Four stat cards at the top showing total Artists, Albums, Songs, and Genres — counts derived from the library in parallel.
- **Recently Played**: Horizontal scroll row showing the last played albums with cover art.
- **Most Played**: Ranked list of the most frequently played tracks.
- **Highest Rated**: List of top-rated tracks by user star rating.
- **Genre Chart**: Visual bar chart of the top genres by song and album count.
#### Playlists Page — Redesigned
- Replaced the card grid with a clean list layout.
- **Sort buttons**: Sort by Name, Tracks, or Duration — click again to toggle ascending/descending.
- **Filter input**: Live search across playlist names.
- Play and delete buttons appear on row hover.
#### Favorites — Songs Section Upgraded
- Tracks now display in a full tracklist layout matching Album Detail: separate `#`, Title, Artist, and Duration columns with a header row.
- Artist name is clickable and navigates to the artist page.
- Right-click context menu on any track (Go to Album, Add to Queue, etc.).
- **"Add all to queue"** button (`btn btn-surface`) next to the section title.
#### Context Menu — Go to Album
- New **Go to Album** option (`Disc3` icon) added for `song` and `queue-item` context menu types.
- Only shown when the song has a known `albumId`.
#### Queue Panel — Meta Box
- Now shows: **Title** (no link) → **Artist** (linked to artist page) → **Album** (linked to album page).
- Removed year display and the old title→album link.
#### Random Mix — Hover Persistence
- Track row stays highlighted while its context menu is open via `.context-active` CSS class.
- Highlight is cleared automatically when the context menu closes.
#### Artist Cards — Redesigned
- `ArtistCardLocal` now matches `AlbumCard` exactly: no padding, full-width square cover via `aspect-ratio: 1`, name and meta below.
- Uses `CachedImage` with `coverArtCacheKey` for proper IndexedDB caching.
- Same `flex: 0 0 clamp(140px, 15vw, 180px)` sizing as album cards — artist cards are no longer oversized.
### Fixed
#### Random Albums — Cover Loading & Manual Refresh
- **Removed `renderKey`**: The album grid was fully remounted on every refresh, restarting all 30 IndexedDB image lookups from scratch. Grid is now stable — only data changes, images stay cached.
- **`loadingRef` guard**: Prevents concurrent fetch calls if the auto-cycle timer fires during a manual refresh.
- **Timer race condition**: Manual refresh now calls `clearTimers()` before `load()`, eliminating the race where the auto-cycle timer fired mid-load.
#### Favorites — Artist Navigation
- Arrow nav buttons in the Artists section now use the same CSS classes as the Albums section (`album-row-section`, `album-row-header`, `album-row-nav`) — consistent styling across both rows.
### Changed
- **AlbumDetail** refactored into a thin orchestrator. Logic extracted into `AlbumHeader` (`src/components/AlbumHeader.tsx`) and `AlbumTrackList` (`src/components/AlbumTrackList.tsx`).
- **German i18n**: "Queue" consistently translated as "Warteschlange" throughout — `queue.shuffle`, `favorites.enqueueAll`.
## [1.3.0] - 2026-03-15
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.3.0",
"version": "1.4.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.3.0"
version = "1.4.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.3.0",
"version": "1.4.0",
"identifier": "dev.psysonic.app",
"build": {
"beforeDevCommand": "npm run dev",
+211
View File
@@ -0,0 +1,211 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import CachedImage from './CachedImage';
import { useTranslation } from 'react-i18next';
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 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 BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
const { t } = useTranslation();
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
<h3 className="modal-title">{t('albumDetail.bioModal')}</h3>
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
</div>
</div>
);
}
interface AlbumInfo {
id: string;
name: string;
artist: string;
artistId: string;
year?: number;
genre?: string;
coverArt?: string;
recordLabel?: string;
}
interface AlbumHeaderProps {
info: AlbumInfo;
songs: SubsonicSong[];
coverUrl: string;
coverKey: string;
resolvedCoverUrl: string | null;
isStarred: boolean;
downloadProgress: number | null;
bio: string | null;
bioOpen: boolean;
onToggleStar: () => void;
onDownload: () => void;
onPlayAll: () => void;
onEnqueueAll: () => void;
onBio: () => void;
onCloseBio: () => void;
}
export default function AlbumHeader({
info,
songs,
coverUrl,
coverKey,
resolvedCoverUrl,
isStarred,
downloadProgress,
bio,
bioOpen,
onToggleStar,
onDownload,
onPlayAll,
onEnqueueAll,
onBio,
onCloseBio,
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
<div className="album-detail-header">
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
)}
<div className="album-detail-overlay" aria-hidden="true" />
<div className="album-detail-content">
<button className="btn btn-ghost album-detail-back" onClick={() => navigate(-1)}>
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
{coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
<span className="badge album-detail-badge">{t('common.album')}</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
onClick={() => navigate(`/artist/${info.artistId}`)}
>
{info.artist}
</button>
</p>
<div className="album-detail-info">
{info.year && <span>{info.year}</span>}
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{info.recordLabel && (
<>
<span className="album-info-dot">·</span>
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
</button>
</>
)}
</div>
<div className="album-detail-actions">
<div className="album-detail-actions-primary">
<button className="btn btn-primary" id="album-play-all-btn" onClick={onPlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={onEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={onBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={onDownload}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
</div>
</div>
</div>
</div>
</>
);
}
+186
View File
@@ -0,0 +1,186 @@
import React from 'react';
import { Play, Star } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { Track } from '../store/playerStore';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
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(' · ');
}
function StarRating({ value, onChange }: { value: number; onChange: (r: number) => void }) {
const { t } = useTranslation();
const [hover, setHover] = React.useState(0);
return (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1, 2, 3, 4, 5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
interface AlbumTrackListProps {
songs: SubsonicSong[];
hasVariousArtists: boolean;
currentTrack: Track | null;
isPlaying: boolean;
hoveredSongId: string | null;
setHoveredSongId: (id: string | null) => void;
ratings: Record<string, number>;
starredSongs: Set<string>;
onPlaySong: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
}
export default function AlbumTrackList({
songs,
hasVariousArtists,
currentTrack,
isPlaying,
hoveredSongId,
setHoveredSongId,
ratings,
starredSongs,
onPlaySong,
onRate,
onToggleSongStar,
onContextMenu,
}: AlbumTrackListProps) {
const { t } = useTranslation();
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const discs = new Map<number, SubsonicSong[]>();
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;
const makeTrack = (song: SubsonicSong): 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 (
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div className="col-center">{t('albumDetail.trackFavorite')}</div>
<div className="col-center">{t('albumDetail.trackRating')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{discNums.map(discNum => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span>
CD {discNum}
</div>
)}
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => setHoveredSongId(song.id)}
onMouseLeave={() => setHoveredSongId(null)}
onDoubleClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, makeTrack(song), 'album-song');
}}
role="row"
draggable
onDragStart={e => {
e.dataTransfer.effectAllowed = 'copy';
e.dataTransfer.setData('text/plain', JSON.stringify({ type: 'song', track: makeTrack(song) }));
}}
>
<div
className="track-num"
style={{
cursor: hoveredSongId === song.id ? 'pointer' : 'default',
color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined,
}}
onClick={() => onPlaySong(song)}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar" /><span className="eq-bar" /><span className="eq-bar" /></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div className="track-star-cell">
<button
className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => onRate(song.id, r)}
/>
<div className="track-duration">
{formatDuration(song.duration)}
</div>
<div className="track-meta">
{(song.suffix || song.bitRate) && (
<span className="track-codec">{codecLabel(song)}</span>
)}
</div>
</div>
))}
</div>
))}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
);
}
+15 -16
View File
@@ -1,7 +1,8 @@
import React from 'react';
import { SubsonicArtist, buildCoverArtUrl } from '../api/subsonic';
import { SubsonicArtist, buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { Users } from 'lucide-react';
import CachedImage from './CachedImage';
interface Props {
artist: SubsonicArtist;
@@ -12,16 +13,14 @@ export default function ArtistCardLocal({ artist }: Props) {
const coverId = artist.coverArt || artist.id;
return (
<div
className="artist-card"
onClick={() => navigate(`/artist/${artist.id}`)}
>
<div className="artist-card-avatar" style={{ position: 'relative', overflow: 'hidden' }}>
<div className="artist-card" onClick={() => navigate(`/artist/${artist.id}`)}>
<div className="artist-card-avatar">
{coverId ? (
<img
src={buildCoverArtUrl(coverId, 200)}
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
e.currentTarget.parentElement?.classList.add('fallback-visible');
@@ -31,14 +30,14 @@ export default function ArtistCardLocal({ artist }: Props) {
<Users size={32} color="var(--text-muted)" />
)}
</div>
<div className="artist-card-name" data-tooltip={artist.name}>
{artist.name}
<div className="artist-card-info">
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
</span>
)}
</div>
{typeof artist.albumCount === 'number' && (
<div className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -59,10 +59,10 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
if (artists.length === 0) return null;
return (
<section className="artist-row-section">
<div className="artist-row-header">
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="artist-row-nav">
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
+11 -1
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User } from 'lucide-react';
import { Play, ListPlus, Radio, Star, Download, ChevronRight, User, Disc3 } from 'lucide-react';
import { usePlayerStore, Track } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
@@ -146,6 +146,11 @@ export default function ContextMenu() {
</div>
)}
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
@@ -205,6 +210,11 @@ export default function ContextMenu() {
{t('contextMenu.removeFromQueue')}
</div>
<div className="context-menu-divider" />
{song.albumId && (
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/album/${song.albumId}`))}>
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
+9 -9
View File
@@ -286,18 +286,18 @@ export default function QueuePanel() {
)}
</div>
<div className="queue-current-info">
<h3
className="truncate"
data-tooltip={currentTrack.title}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.title}</h3>
{currentTrack.year && <div className="queue-current-sub">{currentTrack.year}</div>}
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.artist}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>{currentTrack.artist}</div>
<div
className="queue-current-sub truncate"
data-tooltip={currentTrack.album}
style={{ cursor: currentTrack.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
style={{ cursor: currentTrack.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
+37 -1
View File
@@ -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',
+58 -318
View File
@@ -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 (
<div className="star-rating" role="radiogroup" aria-label={t('albumDetail.ratingLabel')}>
{[1,2,3,4,5].map(n => (
<button
key={n}
className={`star ${(hover || value) >= n ? 'filled' : ''}`}
onMouseEnter={() => setHover(n)}
onMouseLeave={() => setHover(0)}
onClick={() => onChange(n)}
aria-label={`${n}`}
role="radio"
aria-checked={(hover || value) >= n}
>
</button>
))}
</div>
);
}
interface BioModalProps { bio: string; onClose: () => void; }
function BioModal({ bio, onClose }: BioModalProps) {
const { t } = useTranslation();
return (
<div className="modal-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={t('albumDetail.bioModal')}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<button className="modal-close" onClick={onClose} aria-label={t('albumDetail.bioClose')}><X size={18} /></button>
<h3 style={{ marginBottom: '1rem', fontFamily: 'var(--font-display)' }}>{t('albumDetail.bioModal')}</h3>
<div className="artist-bio" dangerouslySetInnerHTML={{ __html: sanitizeHtml(bio) }} data-selectable />
</div>
</div>
);
}
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<Awaited<ReturnType<typeof getAlbum>> | null>(null);
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [ratings, setRatings] = useState<Record<string, number>>({});
@@ -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 <div className="empty-state">{t('albumDetail.notFound')}</div>;
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 (
<div className="album-detail animate-fade-in">
{bioOpen && bio && <BioModal bio={bio} onClose={() => setBioOpen(false)} />}
<AlbumHeader
info={info}
songs={songs}
coverUrl={coverUrl}
coverKey={coverKey}
resolvedCoverUrl={resolvedCoverUrl}
isStarred={isStarred}
downloadProgress={downloadProgress}
bio={bio}
bioOpen={bioOpen}
onToggleStar={toggleStar}
onDownload={handleDownload}
onPlayAll={handlePlayAll}
onEnqueueAll={handleEnqueueAll}
onBio={handleBio}
onCloseBio={() => setBioOpen(false)}
/>
<div className="album-detail-header">
{resolvedCoverUrl && (
<div
className="album-detail-bg"
style={{ backgroundImage: `url(${resolvedCoverUrl})` }}
aria-hidden="true"
/>
)}
<div className="album-detail-overlay" aria-hidden="true" />
<div style={{ position: 'relative', zIndex: 1 }}>
<button className="btn btn-ghost" onClick={() => navigate(-1)} style={{ marginBottom: '1rem', gap: '6px' }}>
<ChevronLeft size={16} /> {t('albumDetail.back')}
</button>
<div className="album-detail-hero">
{coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
<div className="album-detail-meta">
<span className="badge" style={{ marginBottom: '0.5rem' }}>{t('common.album')}</span>
<h1 className="album-detail-title">{info.name}</h1>
<p className="album-detail-artist">
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.goToArtist', { artist: info.artist })}
onClick={() => navigate(`/artist/${info.artistId}`)}
>
{info.artist}
</button>
</p>
<div className="album-detail-info">
{info.year && <span>{info.year}</span>}
{info.genre && <span>· {info.genre}</span>}
<span>· {songs.length} Tracks</span>
<span>· {formatDuration(totalDuration)}</span>
{info.recordLabel && (
<>
<span style={{ margin: '0 4px' }}>·</span>
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
>
{info.recordLabel}
</button>
</>
)}
</div>
<div className="album-detail-actions">
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button className="btn btn-primary" id="album-play-all-btn" onClick={handlePlayAll}>
<Play size={16} fill="currentColor" /> {t('albumDetail.playAll')}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
data-tooltip={t('albumDetail.enqueueTooltip')}
>
<ListPlus size={16} /> {t('albumDetail.enqueue')}
</button>
</div>
<button
className="btn btn-ghost"
id="album-star-btn"
onClick={toggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')}
style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }}
>
<Star size={16} fill={isStarred ? "currentColor" : "none"} />
{t('albumDetail.favorite')}
</button>
<button className="btn btn-ghost" id="album-bio-btn" onClick={handleBio}>
<ExternalLink size={16} /> {t('albumDetail.artistBio')}
</button>
{downloadProgress !== null ? (
<div className="download-progress-wrap">
<Download size={14} />
<div className="download-progress-bar">
<div className="download-progress-fill" style={{ width: `${downloadProgress}%` }} />
</div>
<span className="download-progress-pct">{downloadProgress}%</span>
</div>
) : (
<button className="btn btn-ghost" id="album-download-btn" onClick={() => handleDownload(info.name, info.id)}>
<Download size={16} /> {t('albumDetail.download')}{totalSize > 0 ? ` · ${formatSize(totalSize)}` : ''}
</button>
)}
<span className="download-hint">
<Info size={12} />
{t('albumDetail.downloadHintShort')}
</span>
</div>
</div>
</div>
</div>
</div>
<div className="tracklist">
<div className={`tracklist-header${hasVariousArtists ? ' tracklist-va' : ''}`}>
<div style={{ textAlign: 'center' }}>#</div>
<div>{t('albumDetail.trackTitle')}</div>
{hasVariousArtists && <div>{t('albumDetail.trackArtist')}</div>}
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackFavorite')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackRating')}</div>
<div style={{ textAlign: 'center' }}>{t('albumDetail.trackDuration')}</div>
<div>{t('albumDetail.trackFormat')}</div>
</div>
{(() => {
const discs = new Map<number, SubsonicSong[]>();
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 => (
<div key={discNum}>
{isMultiDisc && (
<div className="disc-header">
<span className="disc-icon">💿</span>
CD {discNum}
</div>
)}
{discs.get(discNum)!.map((song, i) => (
<div
key={song.id}
className={`track-row${hasVariousArtists ? ' track-row-va' : ''}${currentTrack?.id === song.id ? ' active' : ''}`}
onMouseEnter={() => 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 }));
}}
>
<div
className="track-num"
style={{ textAlign: 'center', cursor: hoveredSongId === song.id ? 'pointer' : 'default', color: (hoveredSongId === song.id || currentTrack?.id === song.id) ? 'var(--accent)' : undefined }}
onClick={() => handlePlaySong(song)}
>
{hoveredSongId === song.id && currentTrack?.id !== song.id
? <Play size={13} fill="currentColor" />
: currentTrack?.id === song.id && isPlaying
? <div className="eq-bars"><span className="eq-bar"/><span className="eq-bar"/><span className="eq-bar"/></div>
: currentTrack?.id === song.id
? <Play size={13} fill="currentColor" />
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
<span className="track-artist">{song.artist}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
className="btn btn-ghost"
onClick={(e) => 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)' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? "currentColor" : "none"} />
</button>
</div>
<StarRating
value={ratings[song.id] ?? song.userRating ?? 0}
onChange={r => handleRate(song.id, r)}
/>
<div className="track-duration" style={{ textAlign: 'center' }}>
{formatDuration(song.duration)}
</div>
<div className="track-meta" style={{ display: 'flex', alignItems: 'center' }}>
{(song.suffix || song.bitRate) && (
<span className="track-codec" style={{ marginTop: 0 }}>
{codecLabel(song)}
</span>
)}
</div>
</div>
))}
</div>
));
})()}
{/* Total row */}
<div className={`tracklist-total${hasVariousArtists ? ' tracklist-va' : ''}`}>
<span className="tracklist-total-label">{t('albumDetail.trackTotal')}</span>
<span className="tracklist-total-value">{formatDuration(totalDuration)}</span>
</div>
</div>
<AlbumTrackList
songs={songs}
hasVariousArtists={hasVariousArtists}
currentTrack={currentTrack}
isPlaying={isPlaying}
hoveredSongId={hoveredSongId}
setHoveredSongId={setHoveredSongId}
ratings={ratings}
starredSongs={starredSongs}
onPlaySong={handlePlaySong}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
onContextMenu={openContextMenu}
/>
{relatedAlbums.length > 0 && (
<div style={{ padding: '0 var(--space-6) var(--space-8)' }}>
<div style={{ borderTop: '1px solid var(--border-subtle)', marginBottom: '2rem' }} />
<h2 className="section-title" style={{ marginBottom: '1rem' }}>{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<div className="album-related">
<div className="album-related-divider" />
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<div className="album-grid-wrap">
{relatedAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
+63 -36
View File
@@ -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<SubsonicSong[]>([]);
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 && (
<section className="album-row-section">
<div className="album-row-header" style={{ marginBottom: '1rem' }}>
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('favorites.songs')}</h2>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '0.75rem' }}>
<h2 className="section-title" style={{ margin: 0 }}>{t('favorites.songs')}</h2>
<button
className="btn btn-surface"
onClick={() => {
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);
}}
>
<ListPlus size={15} />
{t('favorites.enqueueAll')}
</button>
</div>
<div className="tracklist" style={{ padding: 0 }}>
{songs.map((song) => (
<div
key={song.id}
className="track-row"
style={{ gridTemplateColumns: '36px 1fr 60px' }}
onDoubleClick={() => 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 }));
}}
>
<button
className="btn btn-ghost"
style={{ padding: 4 }}
onClick={(e) => { e.stopPropagation(); playTrack(song, songs); }}
<div className="tracklist-header tracklist-va">
<div className="col-center">#</div>
<div>{t('albumDetail.trackTitle')}</div>
<div>{t('albumDetail.trackArtist')}</div>
<div className="col-center">{t('albumDetail.trackDuration')}</div>
</div>
{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 (
<div
key={song.id}
className="track-row track-row-va"
onDoubleClick={() => 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 }));
}}
>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info">
<span className="track-title" title={song.title}>{song.title}</span>
<span className="track-artist">{song.artist}</span>
<div className="track-num col-center" onClick={() => playTrack(song, songs)} style={{ cursor: 'pointer' }}>
{i + 1}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
</div>
<div className="track-artist-cell">
<span
className="track-artist"
style={{ cursor: song.artistId ? 'pointer' : 'default' }}
onClick={() => song.artistId && navigate(`/artist/${song.artistId}`)}
>{song.artist}</span>
</div>
<div className="track-duration">
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</div>
</div>
<span className="track-duration" style={{ textAlign: 'right' }}>
{Math.floor(song.duration / 60)}:{(song.duration % 60).toString().padStart(2, '0')}
</span>
</div>
))}
);
})}
</div>
</section>
)}
+95 -76
View File
@@ -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 (
<button className={`playlist-sort-btn${active ? ' active' : ''}`} onClick={() => onSort(sortKey)}>
{label}
{active ? (dir === 'asc' ? <ChevronUp size={13} /> : <ChevronDown size={13} />) : null}
</button>
);
}
export default function Playlists() {
const { t } = useTranslation();
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('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 (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<ListMusic size={32} style={{ color: 'var(--accent)' }} />
<h1 className="page-title" style={{ margin: 0 }}>{t('playlists.title')}</h1>
<div className="playlist-page-header">
<h1 className="page-title">{t('playlists.title')}</h1>
<input
className="playlist-filter-input"
type="search"
placeholder={t('playlists.filterPlaceholder')}
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</div>
{loading ? (
<div className="empty-state">{t('playlists.loading')}</div>
<div className="loading-center"><div className="spinner" /></div>
) : playlists.length === 0 ? (
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>
{t('playlists.empty')}
</div>
<div className="empty-state" style={{ whiteSpace: 'pre-line' }}>{t('playlists.empty')}</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '1rem' }}>
{playlists.map(p => (
<div
key={p.id}
style={{
background: 'var(--surface0)',
borderRadius: '12px',
padding: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '1rem',
border: '1px solid var(--surface1)',
transition: 'all 0.2s ease'
}}
className="hover-card"
>
<div>
<h3 style={{ fontSize: '1.1rem', fontWeight: 600, margin: '0 0 0.25rem 0', color: 'var(--text)' }} className="truncate">
{p.name}
</h3>
<p style={{ margin: 0, fontSize: '0.9rem', color: 'var(--subtext0)' }}>
{t('playlists.track', { count: p.songCount })} {Math.floor(p.duration / 60)} {t('playlists.minutes')}
</p>
</div>
<div className="playlist-list">
<div className="playlist-list-header">
<div />
<SortHeader label={t('playlists.colName')} sortKey="name" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colTracks')} sortKey="songCount" current={sortKey} dir={sortDir} onSort={handleSort} />
<SortHeader label={t('playlists.colDuration')} sortKey="duration" current={sortKey} dir={sortDir} onSort={handleSort} />
<div />
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: 'auto' }}>
<button
className="btn btn-primary"
onClick={() => handlePlay(p.id)}
style={{ flex: 1, display: 'flex', justifyContent: 'center', alignItems: 'center', gap: '0.5rem' }}
>
<Play size={16} fill="currentColor" /> {t('playlists.play')}
</button>
<button
className="btn btn-ghost"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip={t('playlists.deleteTooltip')}
style={{ width: '42px', display: 'flex', justifyContent: 'center', alignItems: 'center', color: 'var(--red)' }}
>
<Trash2 size={18} />
</button>
</div>
{visible.length === 0 ? (
<div className="empty-state">{t('playlists.noResults')}</div>
) : visible.map(p => (
<div key={p.id} className="playlist-row">
<button className="playlist-play-icon" onClick={() => handlePlay(p.id)} data-tooltip={t('playlists.play')}>
<Play size={14} fill="currentColor" />
</button>
<span className="playlist-name truncate">{p.name}</span>
<span className="playlist-meta">{t('playlists.track', { count: p.songCount })}</span>
<span className="playlist-meta">{formatDuration(p.duration)}</span>
<button
className="btn btn-ghost playlist-delete-btn"
onClick={() => handleDelete(p.id, p.name)}
data-tooltip={t('playlists.deleteTooltip')}
>
<Trash2 size={15} />
</button>
</div>
))}
</div>
+14 -16
View File
@@ -11,38 +11,38 @@ export default function RandomAlbums() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [renderKey, setRenderKey] = useState(0);
const [progress, setProgress] = useState(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const progressRef = useRef<ReturnType<typeof setInterval> | 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() {
<div className="spinner" />
</div>
) : (
<div className="album-grid-wrap animate-fade-in" key={renderKey}>
<div className="album-grid-wrap">
{albums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}
+16 -2
View File
@@ -42,6 +42,9 @@ export default function RandomMix() {
const [songs, setSongs] = useState<SubsonicSong[]>([]);
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<string | null>(null);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const { excludeAudiobooks, setExcludeAudiobooks, customGenreBlacklist, setCustomGenreBlacklist } = useAuthStore();
const [addedGenre, setAddedGenre] = useState<string | null>(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() {
<span style={{ textAlign: 'right' }}>{t('randomMix.trackDuration')}</span>
</div>
{genreMixSongs.map(song => (
<div key={song.id} className="track-row" style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
<div key={song.id} className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`} style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 80px' }}
onDoubleClick={() => 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) => (
<div
key={song.id}
className="track-row"
className={`track-row${contextMenuSongId === song.id ? ' context-active' : ''}`}
style={{ gridTemplateColumns: '36px 1fr 1fr 1fr 120px 60px 80px' }}
onDoubleClick={() => 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 = {
+59 -47
View File
@@ -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<SubsonicAlbum[]>([]);
const [frequent, setFrequent] = useState<SubsonicAlbum[]>([]);
const [highest, setHighest] = useState<SubsonicAlbum[]>([]);
const [genres, setGenres] = useState<SubsonicGenre[]>([]);
const [artistCount, setArtistCount] = useState<number | null>(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 (
<div className="content-body animate-fade-in">
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '2rem' }}>
<BarChart3 size={32} style={{ color: 'var(--accent)' }} />
<h1 className="page-title" style={{ margin: 0 }}>{t('statistics.title')}</h1>
</div>
<h1 className="page-title">{t('statistics.title')}</h1>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<div className="spinner" />
</div>
<div className="loading-center"><div className="spinner" /></div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '3rem' }}>
<div className="stats-page">
<div className="stats-overview">
{stats.map(s => (
<div key={s.label} className="stats-card">
<span className="stats-card-value">{s.value?.toLocaleString() ?? '—'}</span>
<span className="stats-card-label">{s.label}</span>
</div>
))}
</div>
{recent.length > 0 && (
<AlbumRow title={t('statistics.recentlyPlayed')} albums={recent} />
)}
<AlbumRow
title={t('statistics.mostPlayed')}
@@ -72,36 +91,29 @@ export default function Statistics() {
/>
{genres.length > 0 && (
<div>
<div className="section-title">
<h2>{t('statistics.genreDistribution')}</h2>
</div>
<div style={{ display: 'grid', gap: '1rem', background: 'var(--surface0)', padding: '1.5rem', borderRadius: '12px' }}>
{genres.map(genre => {
const percentage = (genre.songCount / maxGenreCount) * 100;
return (
<div key={genre.value} style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', color: 'var(--text)' }}>
<span>{genre.value}</span>
<span style={{ color: 'var(--subtext0)' }}>{genre.songCount} Songs</span>
</div>
<div style={{ width: '100%', height: '8px', background: 'var(--surface2)', borderRadius: '4px', overflow: 'hidden' }}>
<div
style={{
width: `${percentage}%`,
height: '100%',
background: 'var(--accent)',
borderRadius: '4px',
transition: 'width 1s ease-out'
}}
/>
</div>
<section>
<h2 className="section-title">{t('statistics.genreDistribution')}</h2>
<div className="genre-chart">
{genres.map(genre => (
<div key={genre.value} className="genre-row">
<div className="genre-row-header">
<span className="genre-name">{genre.value}</span>
<span className="genre-counts">
{t('statistics.genreSongs', { count: genre.songCount })}
{' · '}
{t('statistics.genreAlbums', { count: genre.albumCount })}
</span>
</div>
);
})}
<div className="genre-bar-track">
<div
className="genre-bar-fill"
style={{ width: `${(genre.songCount / maxGenreCount) * 100}%` }}
/>
</div>
</div>
))}
</div>
</div>
</section>
)}
</div>
+239 -23
View File
@@ -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;
}