diff --git a/CHANGELOG.md b/CHANGELOG.md index 63d66efa..a6436513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 051ea41c..0d2d5623 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.3.0", + "version": "1.4.0", "private": true, "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a4e5b8a..c10202bf 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.3.0" +version = "1.4.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2b1e983a..aaee16ed 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx new file mode 100644 index 00000000..54cb6a27 --- /dev/null +++ b/src/components/AlbumHeader.tsx @@ -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 ( +
+
e.stopPropagation()}> + +

{t('albumDetail.bioModal')}

+
+
+
+ ); +} + +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 && } + +
+ {resolvedCoverUrl && ( +