Compare commits

...

3 Commits

Author SHA1 Message Date
Psychotoxical af18aef42a fix: random albums performance, image cache memory leak, i18n fix (v1.4.1)
- Remove auto-refresh timer from Random Albums (caused 100ms progress
  interval + 30 concurrent fetches every 30s, eventually freezing the app)
- Limit concurrent image fetches to 5 (was unbounded)
- Cap in-memory object URL cache at 150 entries with revokeObjectURL eviction
- Add cancellation flag to useCachedUrl to prevent setState on unmounted components
- Fix hardcoded "Neueste" page title in New Releases (now uses i18n)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 19:09:31 +01:00
Psychotoxical d3ffa30bf5 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>
2026-03-16 17:36:58 +01:00
Psychotoxical f666f84479 chore: switch license from MIT to GPL v3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-15 21:33:02 +01:00
23 changed files with 1143 additions and 615 deletions
+67
View File
@@ -5,6 +5,73 @@ 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.1] - 2026-03-16
### Fixed
#### Random Albums — Performance & Memory
- **Auto-refresh removed**: The 30-second auto-cycle timer caused 10 React state updates/second (progress bar interval) and a burst of 30 concurrent image fetches on every tick, eventually making the whole app unresponsive. The page now loads once on mount; use the manual refresh button to get a new selection.
- **Concurrent fetch limit**: Image fetches are now capped at 5 simultaneous network requests (was unlimited — 30 at once on every refresh).
- **Object URL memory leak**: The in-memory image cache now caps at 150 entries and revokes old object URLs via `URL.revokeObjectURL()` when evicting. Previously, object URLs accumulated without bound across the entire session.
- **Dangling state updates**: `useCachedUrl` now uses a cancellation flag — if a component unmounts while a fetch is in flight (e.g. during a grid refresh), the resolved URL is discarded instead of calling `setState` on an unmounted component.
#### i18n
- Page title "Neueste" on the New Releases page was hardcoded German. Now uses `t('sidebar.newReleases')`.
## [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
+16 -17
View File
@@ -1,21 +1,20 @@
MIT License
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (c) 2026 Psychotoxical
Copyright (C) 2026 Psychotoxical
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
The full license text is available at:
https://www.gnu.org/licenses/gpl-3.0.txt
+4 -2
View File
@@ -5,7 +5,7 @@
<p>
<a href="https://github.com/Psychotoxical/psysonic/releases/latest"><img alt="Latest Release" src="https://img.shields.io/github/v/release/Psychotoxical/psysonic?style=flat-square&color=8839ef"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License" src="https://img.shields.io/github/license/Psychotoxical/psysonic?style=flat-square&color=cba6f7"></a>
<a href="https://github.com/Psychotoxical/psysonic/blob/main/LICENSE"><img alt="License: GPL v3" src="https://img.shields.io/badge/License-GPLv3-cba6f7?style=flat-square"></a>
<a href="https://tauri.app/"><img alt="Built with Tauri" src="https://img.shields.io/badge/Built%20with-Tauri-242938?style=flat-square&logo=tauri"></a>
</p>
</div>
@@ -94,4 +94,6 @@ Contributions are completely welcome! Whether it is translating the app into a n
## 📄 License
Distributed under the MIT License. See `LICENSE` for more information.
Distributed under the **GNU General Public License v3.0**. See `LICENSE` for more information.
This means: you are free to use, study, and modify Psysonic. If you distribute a modified version, you must release it under the same GPL v3 license and keep the original copyright notice intact. You may **not** incorporate this code into proprietary software.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.3.0",
"version": "1.4.1",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.3.0"
version = "1.4.1"
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.1",
"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')}
+3 -1
View File
@@ -10,7 +10,9 @@ export function useCachedUrl(fetchUrl: string, cacheKey: string): string {
const [resolved, setResolved] = useState('');
useEffect(() => {
if (!fetchUrl) { setResolved(''); return; }
getCachedUrl(fetchUrl, cacheKey).then(setResolved);
let cancelled = false;
getCachedUrl(fetchUrl, cacheKey).then(url => { if (!cancelled) setResolved(url); });
return () => { cancelled = true; };
}, [fetchUrl, cacheKey]);
return resolved || fetchUrl;
}
+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>
)}
+3 -1
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import AlbumCard from '../components/AlbumCard';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
export default function NewReleases() {
const { t } = useTranslation();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(0);
@@ -49,7 +51,7 @@ export default function NewReleases() {
return (
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>Neueste</h1>
<h1 className="page-title" style={{ marginBottom: '1.5rem' }}>{t('sidebar.newReleases')}</h1>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '3rem' }}>
+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>
+7 -43
View File
@@ -4,61 +4,30 @@ import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import { useTranslation } from 'react-i18next';
const INTERVAL_MS = 30000;
const ALBUM_COUNT = 30;
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 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
setProgress(0);
const startTime = Date.now();
progressRef.current = setInterval(() => {
const elapsed = Date.now() - startTime;
setProgress(Math.min((elapsed / INTERVAL_MS) * 100, 100));
}, 100);
// Auto-refresh
timerRef.current = setInterval(() => {
load().then(() => startCycle());
}, INTERVAL_MS);
}, [load]);
useEffect(() => {
load().then(() => startCycle());
return () => {
if (timerRef.current) clearInterval(timerRef.current);
if (progressRef.current) clearInterval(progressRef.current);
};
}, [load, startCycle]);
const handleManualRefresh = () => {
load().then(() => startCycle());
};
useEffect(() => { load(); }, [load]);
return (
<div className="content-body animate-fade-in">
@@ -66,7 +35,7 @@ export default function RandomAlbums() {
<h1 className="page-title" style={{ marginBottom: 0 }}>{t('randomAlbums.title')}</h1>
<button
className="btn btn-ghost"
onClick={handleManualRefresh}
onClick={load}
disabled={loading}
data-tooltip={t('randomAlbums.refresh')}
>
@@ -75,17 +44,12 @@ export default function RandomAlbums() {
</button>
</div>
{/* Countdown progress bar */}
<div className="random-albums-progress">
<div className="random-albums-progress-fill" style={{ width: `${progress}%` }} />
</div>
{loading && albums.length === 0 ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '4rem' }}>
<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>
+240 -36
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;
@@ -307,24 +313,13 @@
gap: var(--space-4);
}
.random-albums-progress {
height: 2px;
background: var(--border-subtle);
border-radius: var(--radius-full);
margin-bottom: 1.5rem;
overflow: hidden;
}
.random-albums-progress-fill {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.1s linear;
}
@media (min-width: 1024px) {
.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 +514,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 +571,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 +635,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 +712,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 +746,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 +1478,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;
}
+37 -3
View File
@@ -1,10 +1,39 @@
const DB_NAME = 'psysonic-img-cache';
const STORE_NAME = 'images';
const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
const MAX_MEMORY_CACHE = 150; // max object URLs kept in RAM
const MAX_CONCURRENT_FETCHES = 5;
// In-memory map: cacheKey → object URL (avoids creating multiple object URLs per session)
// In-memory map: cacheKey → object URL (insertion-order = LRU approximation)
const objectUrlCache = new Map<string, string>();
// Concurrency limiter for network fetches
let activeFetches = 0;
const fetchQueue: Array<() => void> = [];
function acquireFetchSlot(): Promise<void> {
if (activeFetches < MAX_CONCURRENT_FETCHES) {
activeFetches++;
return Promise.resolve();
}
return new Promise(resolve => fetchQueue.push(resolve));
}
function releaseFetchSlot(): void {
activeFetches--;
const next = fetchQueue.shift();
if (next) { activeFetches++; next(); }
}
function evictIfNeeded(): void {
while (objectUrlCache.size > MAX_MEMORY_CACHE) {
const oldestKey = objectUrlCache.keys().next().value;
if (!oldestKey) break;
URL.revokeObjectURL(objectUrlCache.get(oldestKey)!);
objectUrlCache.delete(oldestKey);
}
}
let db: IDBDatabase | null = null;
let dbPromise: Promise<IDBDatabase> | null = null;
@@ -75,10 +104,12 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
if (blob) {
const objUrl = URL.createObjectURL(blob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
return objUrl;
}
// 3. Network fetch → store in IDB → return object URL
// 3. Network fetch with concurrency limit → store in IDB → return object URL
await acquireFetchSlot();
try {
const resp = await fetch(fetchUrl);
if (!resp.ok) return fetchUrl;
@@ -86,8 +117,11 @@ export async function getCachedUrl(fetchUrl: string, cacheKey: string): Promise<
putBlob(cacheKey, newBlob); // fire-and-forget
const objUrl = URL.createObjectURL(newBlob);
objectUrlCache.set(cacheKey, objUrl);
evictIfNeeded();
return objUrl;
} catch {
return fetchUrl; // fallback: direct URL
return fetchUrl;
} finally {
releaseFetchSlot();
}
}