Compare commits

..

3 Commits

Author SHA1 Message Date
Psychotoxical d927ef2082 feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 13:10:06 +01:00
Psychotoxical 867c5fbd3e feat: v1.11.0 — Movies themes, Settings polish, Gapless stable
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 02:37:49 +01:00
Psychotoxical e550340565 feat: v1.10.0 — new streaming themes, favourite toggle fix, home page improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 22:58:05 +01:00
40 changed files with 3387 additions and 672 deletions
+90
View File
@@ -5,6 +5,96 @@ 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.12.0] - 2026-03-22
### Added
- **Synchronized Lyrics**: Lyrics pane integrated into the Queue sidebar, powered by [LRCLIB](https://lrclib.net) — no API key required. Shows time-synced lyrics with auto-scroll and active-line highlighting; falls back to plain text when synced lyrics are unavailable. Access via the microphone icon in the player bar, fullscreen player, or Now Playing page.
#### 15 New Themes
**Games** (new group — 6 themes):
- **Ascalon**: Dark stone fantasy inspired by Guild Wars 1. Near-black base, gold accent (`#d4af37`).
- **Azerothian Gold**: World of Warcraft inspired. Charcoal base, warm gold accent (`#c19e67`).
- **Grand Theft Audio**: GTA-inspired night city aesthetic. Pure black base, green accent (`#57b05a`).
- **Lambda 17**: Half-Life inspired. Deep blue-black base, amber accent (`#ff9d00`).
- **NightCity 2077**: Cyberpunk 2077 inspired. Near-total black base, neon yellow accent (`#FCEE0A`).
- **V-Tactical**: Battlefield inspired. Gunmetal base, burnt orange accent (`#ff8a00`).
**Series** (new group — 3 themes):
- **A Theme of Ice and Fire**: Game of Thrones inspired. Cold dark navy base, ice blue accent (`#70a1ff`).
- **D'oh-matic**: The Simpsons inspired. Cream/yellow light base, blue accent (`#1F75FE`).
- **Heisenberg**: Breaking Bad inspired. Dark desaturated green base, crystal blue accent (`#3fe0ff`).
**Movies** (2 additions):
- **Imperial Sith**: Star Wars dark side. Pure black base, red accent (`#e60000`).
- **Order of the Phoenix**: Harry Potter inspired. Deep charcoal base, ember-orange accent (`#e63900`).
**Operating Systems** (1 addition):
- **W98**: Windows 98 teal desktop aesthetic. Classic teal background, silver card, navy accent (`#000080`).
### Changed
- **Last.fm integration**: Promoted out of beta — scrobbling, Now Playing, love/unlove, Similar Artists, and top stats are considered stable.
- **Crossfade**: No longer marked experimental. Stable on Windows and Linux; macOS under observation.
- **Gapless playback**: Experimental badge removed — considered stable.
- **Theme picker — groups reorganised**: Catppuccin, Nord, and Retro (Gruvbox) merged into a single **Open Source Classics** group. Streaming themes (Spotless, DZR, Cupertino Beats) moved into **Psysonic Themes — Mediaplayer**. The app now ships **47 themes** across **7 groups**.
- **Tokyo Night themes removed**: `tokyo-night`, `tokyo-night-storm`, and `tokyo-night-light` retired to make room for the new groups.
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
### Fixed
- **Text selection on double-click**: Double-clicking song titles or anywhere in the UI no longer accidentally selects text. `user-select: none` applied globally; re-enabled for bio/description text areas.
- **Middle Earth theme — star buttons**: Active favourite star in the album tracklist and album header was barely visible (gold on parchment, ~1.4:1 contrast). Both active and inactive states now use darker brown tones with proper contrast.
- **Middle Earth theme — play button hover**: Hovering the primary play/pause button no longer makes the icon invisible (gold icon on gold background).
## [1.11.0] - 2026-03-22
### Added
#### Five New Themes — Movies
- **Middle Earth**: Warm parchment light theme. Cream/beige background, dark ebony player and sidebar, gold accent (`#d4af37`). Georgia serif for track names, subtle noise texture.
- **Morpheus**: Pure black terminal aesthetic inspired by The Matrix. Phosphor green accent (`#00ff41`), monospace font.
- **Pandora**: Deep bioluminescent navy inspired by Avatar. Cyan accent (`#00f2ff`), large radii, glow effects.
- **Stark HUD**: Near-black tactical HUD inspired by Iron Man. Cyan accent, JetBrains Mono, uppercase track name.
- **Blade**: Deep black with blood-red accent (`#b30000`). Sharp radii, uppercase track name.
- All five themes in a new **Movies** group in the theme picker.
### Changed
- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About.
- **Settings — Appearance**: Language selector moved to the top of the tab, above Theme and Font.
- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems".
- **Default font**: Changed from Inter to **Lexend** for new installations.
- **Gapless playback**: Experimental badge removed — gapless is now considered stable.
- **Now Playing — background**: Ken Burns animation (40 s, subtle scale + translate). Background blur increased to eliminate JPEG block artefacts at high blur values.
- **Now Playing — Similar Artists**: Tag cloud redesigned into 2 rows with varied font sizes and vertical offsets for a natural look.
- **Statistics**: "Now Playing" indicator rendered as a styled badge matching the app's badge style.
## [1.10.0] - 2026-03-22
### Added
#### Three New Themes (Streaming Series)
- **Spotless**: Flat dark theme inspired by modern music streaming. Pitch-black sidebar (`#000000`), dark-grey app background (`#121212`), Spotify-green accent (`#1ED760`). White play button, green hover on primary actions.
- **DZR**: Flat light theme inspired by Deezer's modern redesign. White base, light-grey sidebar (`#F5F5F7`), purple accent (`#A238FF`). Crisp typography, large rounded radii.
- **Cupertino Beats**: Apple Music-inspired dark theme. Near-black base (`#1c1c1e`), frosted-glass sidebar and player bar with heavy `backdrop-filter`, red accent (`#fa243c`). Active nav links styled with `accent-dim` background.
- All three themes added to the **Psysonic Themes — Mediaplayer** group in the theme picker.
### Fixed
- **Favourite/Unfavourite toggle**: Right-clicking a song, album, or artist that is already starred now shows "Remove from Favourites" and calls `unstar()` correctly. Previously always showed "Add to Favourites" regardless of starred state.
- `Track` interface gained `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- `starredOverrides: Record<string, boolean>` added to `playerStore` — updated immediately on star/unstar so the context menu and tracklist star icons reflect changes without a page reload.
- **Home page — Artist Discovery**: Replaced card grid (which loaded artist images and caused performance issues) with lightweight pill-buttons — same `artist-ext-link` style as the "Similar Artists" section on artist pages. No image loading, instant render.
- **Now Playing page**: Queue sidebar is no longer automatically hidden when entering the Now Playing page. It now behaves like all other pages and respects the user's current queue visibility setting.
- **Random Mix filter panel**: Background now correctly uses `--bg-card` instead of the undefined `--bg-elevated` token, which caused the panel to render transparent in most themes.
### Changed
- **Home page layout**: Section order is now: Recently Added → Discover → Artist Discovery → Starred → Most Played.
## [1.9.0] - 2026-03-21
### Added
+47 -25
View File
@@ -50,7 +50,10 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
| `src/store/themeStore.ts` | Theme selection (30 themes), applied as `data-theme` on `<html>` |
| `src/store/themeStore.ts` | Theme selection (47 themes across 7 groups), applied as `data-theme` on `<html>` |
| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. |
| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. |
| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. |
| `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on `<html>`. Persisted in `psysonic_font`. |
| `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. |
| `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. |
@@ -110,7 +113,7 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
30 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
47 themes across 7 groups, selectable in Settings via `ThemePicker`. `themeStore` persists the choice and sets `data-theme` on `<html>`. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set.
`--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
@@ -125,27 +128,44 @@ Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper
| `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` |
| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `mocha` | Catppuccin | dark | Mauve |
| `macchiato` | Catppuccin | medium-dark | Mauve |
| `frappe` | Catppuccin | medium | Mauve |
| `latte` | Catppuccin | light | Mauve |
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` |
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` |
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` |
| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` |
| `mocha` | Open Source Classics | Catppuccin dark | Mauve |
| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve |
| `frappe` | Open Source Classics | Catppuccin medium | Mauve |
| `latte` | Open Source Classics | Catppuccin light | Mauve |
| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` |
| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` |
| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` |
| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` |
| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` |
| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` |
| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` |
| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` |
| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` |
| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` |
**Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm).
@@ -207,7 +227,7 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **AlbumDetail**: Thin orchestrator (`src/pages/AlbumDetail.tsx`) — state, handlers, `useCachedUrl` hook, renders `AlbumHeader` + `AlbumTrackList` + related albums section. Logic is split into the two extracted components.
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite/Unfavorite" toggle. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. Starred state is read from `item.starred` (set when the item was loaded) and overridden by `starredOverrides` in `playerStore` (updated immediately on star/unstar so the UI reflects the change without a page reload). `Track` interface includes `starred?: string` — propagated via `songToTrack()` and all inline track-object construction sites.
- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
@@ -222,4 +242,6 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
- **Version**: 1.9.0
- **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact.
- **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors.
- **Version**: 1.12.0
+7 -10
View File
@@ -22,11 +22,12 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features
- 🎨 **Gorgeous UI**: 30 deeply integrated themes across 7 groups — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme (Cupertino, Aero Glass, Luna Teal), and Psysonic originals (WnAmp, Poison, Nucleo, Neon Drift…) — with smooth glassmorphism effects and micro-animations.
- 🎨 **Gorgeous UI**: 47 deeply integrated themes across 7 groups — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Psysonic Mediaplayer — with smooth glassmorphism effects and micro-animations.
-**Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
- 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine.
- 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
@@ -45,25 +46,21 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] 10-band graphic EQ with built-in and custom presets
- [x] Crossfade between tracks
- [x] Replay Gain (track + album mode)
- [x] Gapless playback *(experimental)*
- [x] Gapless playback
- [x] Waveform seekbar
- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)*
- [x] Last.fm scrobbling, Now Playing & love/unlove
- [x] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
- [x] 30 themes across 7 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme, Psysonic originals, Psysonic Mediaplayer
- [x] 47 themes across 7 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Psysonic originals, Psysonic Mediaplayer
- [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings
- [x] Font picker (10 UI fonts)
### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements
### 📋 Planned
- [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.9.0",
"version": "1.12.0",
"private": true,
"scripts": {
"dev": "vite",
+2 -2
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
# Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic
pkgver=1.9.0
pkgver=1.12.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.9.0"
version = "1.12.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.9.0"
version = "1.12.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+349 -51
View File
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize;
use tauri::{AppHandle, Emitter, State};
@@ -209,7 +210,7 @@ impl<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
(t * std::f32::consts::FRAC_PI_2).sin()
};
self.sample_count += 1;
Some(sample * gain)
Some((sample * gain).clamp(-1.0, 1.0))
}
}
@@ -226,6 +227,82 @@ impl<S: Source<Item = f32>> Source for EqualPowerFadeIn<S> {
}
}
// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─
//
// Every track source is wrapped with this. It passes through at unity gain
// until `trigger` is set to true, at which point it reads `fade_total_samples`
// and applies a cos(t·π/2) envelope:
// gain(t) = cos(t · π/2), t ∈ [0, 1]
// At t = 0 gain = 1, at t = 1 gain = 0.
// After the fade completes, returns None to exhaust the source.
//
// Combined with EqualPowerFadeIn (sin curve) on Track B, this gives a
// symmetric constant-power crossfade: sin²+cos² = 1.
struct TriggeredFadeOut<S: Source<Item = f32>> {
inner: S,
trigger: Arc<AtomicBool>,
fade_total_samples: Arc<AtomicU64>,
fade_progress: u64,
fading: bool,
cached_total: u64,
}
impl<S: Source<Item = f32>> TriggeredFadeOut<S> {
fn new(inner: S, trigger: Arc<AtomicBool>, fade_total_samples: Arc<AtomicU64>) -> Self {
Self {
inner,
trigger,
fade_total_samples,
fade_progress: 0,
fading: false,
cached_total: 0,
}
}
}
impl<S: Source<Item = f32>> Iterator for TriggeredFadeOut<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
// Check trigger on first fade sample only (avoid atomic load per sample).
if !self.fading && self.trigger.load(Ordering::Relaxed) {
self.fading = true;
self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1);
self.fade_progress = 0;
}
if self.fading {
if self.fade_progress >= self.cached_total {
// Fade complete — exhaust the source.
return None;
}
let sample = self.inner.next()?;
let t = self.fade_progress as f32 / self.cached_total as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
self.fade_progress += 1;
Some((sample * gain).clamp(-1.0, 1.0))
} else {
self.inner.next()
}
}
}
impl<S: Source<Item = f32>> Source for TriggeredFadeOut<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// If we seek back during a fade, cancel the fade.
if self.fading {
self.fading = false;
self.trigger.store(false, Ordering::Relaxed);
}
self.fade_progress = 0;
self.inner.try_seek(pos)
}
}
// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
//
// This is the key mechanism for gapless: the progress task polls `done` to know
@@ -269,6 +346,48 @@ impl<S: Source<Item = f32>> Source for NotifyingSource<S> {
}
}
// ─── CountingSource — atomic sample counter for drift-free position tracking ─
//
// Wraps the outermost source and increments a shared AtomicU64 on every sample.
// The progress task reads this counter and divides by (sample_rate * channels)
// to get the exact playback position — no wall-clock drift.
struct CountingSource<S: Source<Item = f32>> {
inner: S,
counter: Arc<AtomicU64>,
}
impl<S: Source<Item = f32>> CountingSource<S> {
fn new(inner: S, counter: Arc<AtomicU64>) -> Self {
Self { inner, counter }
}
}
impl<S: Source<Item = f32>> Iterator for CountingSource<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
let sample = self.inner.next();
if sample.is_some() {
self.counter.fetch_add(1, Ordering::Relaxed);
}
sample
}
}
impl<S: Source<Item = f32>> Source for CountingSource<S> {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.inner.channels() }
fn sample_rate(&self) -> u32 { self.inner.sample_rate() }
fn total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
// Reset counter to the sought position in samples.
let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64
* self.inner.channels() as f64) as u64;
self.counter.store(samples, Ordering::Relaxed);
self.inner.try_seek(pos)
}
}
// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
//
// MP3/AAC encoders prepend an "encoder delay" (typically 5762112 silent
@@ -326,12 +445,28 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
}
/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify.
/// Result of build_source: the fully-wrapped source plus metadata and control Arcs.
struct BuiltSource {
source: CountingSource<NotifyingSource<TriggeredFadeOut<EqualPowerFadeIn<EqSource<DynSource>>>>>,
duration_secs: f64,
output_rate: u32,
output_channels: u16,
/// Trigger for the sample-level crossfade fade-out.
fadeout_trigger: Arc<AtomicBool>,
/// Total samples for the fade-out (set before triggering).
fadeout_samples: Arc<AtomicU64>,
}
/// Build a fully-prepared playback source:
/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count
///
/// `fade_in_dur`:
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
///
/// `sample_counter`: atomic counter incremented per sample for drift-free position.
/// `target_rate`: canonical output sample rate for resampling (0 = no resampling).
fn build_source(
data: Vec<u8>,
duration_hint: f64,
@@ -339,12 +474,15 @@ fn build_source(
eq_enabled: Arc<AtomicBool>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
) -> Result<(NotifyingSource<EqualPowerFadeIn<EqSource<DynSource>>>, f64), String> {
sample_counter: Arc<AtomicU64>,
target_rate: u32,
) -> Result<BuiltSource, String> {
let gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
// Determine effective duration.
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
@@ -356,7 +494,8 @@ fn build_source(
.unwrap_or(duration_hint)
};
// Apply encoder-delay trim and optional end-padding trim.
// Apply encoder-delay trim and optional end-padding trim,
// then resample to the canonical target rate if needed.
let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
let delay_dur = Duration::from_secs_f64(
gapless.delay_samples as f64 / sample_rate as f64
@@ -365,19 +504,47 @@ fn build_source(
if let Some(total) = gapless.total_valid_samples {
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
DynSource::new(base.take_duration(valid_dur))
let trimmed = base.take_duration(valid_dur);
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate))
} else {
DynSource::new(trimmed)
}
} else {
DynSource::new(base)
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(base, channels, target_rate))
} else {
DynSource::new(base)
}
}
} else {
DynSource::new(decoder.convert_samples::<f32>())
let converted = decoder.convert_samples::<f32>();
if target_rate > 0 && sample_rate != target_rate {
DynSource::new(UniformSourceIterator::new(converted, channels, target_rate))
} else {
DynSource::new(converted)
}
};
let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate };
let fadeout_trigger = Arc::new(AtomicBool::new(false));
let fadeout_samples = Arc::new(AtomicU64::new(0));
let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
let notifying = NotifyingSource::new(fade_in, done_flag);
let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone());
let notifying = NotifyingSource::new(fade_out, done_flag);
let counting = CountingSource::new(notifying, sample_counter);
Ok((notifying, effective_dur))
Ok(BuiltSource {
source: counting,
duration_secs: effective_dur,
output_rate,
output_channels: channels,
fadeout_trigger,
fadeout_samples,
})
}
// ─── Engine state ─────────────────────────────────────────────────────────────
@@ -397,6 +564,9 @@ pub(crate) struct ChainedInfo {
base_volume: f32,
/// Set by NotifyingSource when this chained track's source is exhausted.
source_done: Arc<AtomicBool>,
/// Atomic sample counter for this chained source (swapped into
/// samples_played on transition).
sample_counter: Arc<AtomicU64>,
}
pub struct AudioEngine {
@@ -417,6 +587,16 @@ pub struct AudioEngine {
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
/// Atomic sample counter — incremented by CountingSource in the audio thread.
/// Progress task reads this for drift-free position tracking.
pub samples_played: Arc<AtomicU64>,
/// Sample rate of the currently playing source (for samples → seconds).
pub current_sample_rate: Arc<AtomicU32>,
/// Channel count of the currently playing source.
pub current_channels: Arc<AtomicU32>,
/// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless
/// auto-advance. Commands arriving within 500 ms are rejected as ghost commands.
pub gapless_switch_at: Arc<AtomicU64>,
}
pub struct AudioCurrent {
@@ -427,6 +607,10 @@ pub struct AudioCurrent {
pub paused_at: Option<f64>,
pub replay_gain_linear: f32,
pub base_volume: f32,
/// Crossfade: trigger for sample-level fade-out of the current source.
pub fadeout_trigger: Option<Arc<AtomicBool>>,
/// Crossfade: total fade samples (set before triggering).
pub fadeout_samples: Option<Arc<AtomicU64>>,
}
impl AudioCurrent {
@@ -459,6 +643,16 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
}
}
// macOS: request a smaller CoreAudio buffer to reduce output latency.
// Smaller buffers = lower latency between decoded samples and DAC output,
// which tightens the gap between actual audio and UI event delivery.
#[cfg(target_os = "macos")]
{
if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() {
std::env::set_var("COREAUDIO_BUFFER_SIZE", "512");
}
}
let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into())
.spawn(move || match rodio::OutputStream::try_default() {
@@ -482,6 +676,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
paused_at: None,
replay_gain_linear: 1.0,
base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})),
generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder()
@@ -496,6 +692,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)),
samples_played: Arc::new(AtomicU64::new(0)),
current_sample_rate: Arc::new(AtomicU32::new(44100)),
current_channels: Arc::new(AtomicU32::new(2)),
gapless_switch_at: Arc::new(AtomicU64::new(0)),
};
(engine, thread)
@@ -574,6 +774,24 @@ pub async fn audio_play(
) -> Result<(), String> {
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Ghost-command guard ───────────────────────────────────────────────────
// After a gapless auto-advance, the frontend may fire a stale playTrack()
// call via IPC. If we're within 500 ms of the last gapless switch AND the
// requested URL matches the already-playing chained track, reject it.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
// Within the guard window — suppress this ghost command.
return Ok(());
}
}
}
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
@@ -640,16 +858,29 @@ pub async fn audio_play(
Duration::from_millis(5)
};
// Build source: decode → trim → EQ → fade-in → notify.
// Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count.
let done_flag = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Reset sample counter for the new track.
state.samples_played.store(0, Ordering::Relaxed);
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_flag.clone(),
fade_in_dur,
state.samples_played.clone(),
target_rate,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
let source = built.source;
let duration_secs = built.duration_secs;
let output_rate = built.output_rate;
let output_channels = built.output_channels;
// Store the actual output rate/channels for position calculation.
state.current_sample_rate.store(output_rate, Ordering::Relaxed);
state.current_channels.store(output_channels as u32, Ordering::Relaxed);
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
@@ -681,11 +912,12 @@ pub async fn audio_play(
sink.append(source);
// Atomically swap sinks.
let (old_sink, old_vol) = {
// Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
let old = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink);
cur.duration_secs = duration_secs;
cur.seek_offset = 0.0;
@@ -693,39 +925,33 @@ pub async fn audio_play(
cur.paused_at = None;
cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0);
(old, old_vol)
cur.fadeout_trigger = Some(built.fadeout_trigger);
cur.fadeout_samples = Some(built.fadeout_samples);
(old, old_fo_trigger, old_fo_samples)
};
// Handle old sink: equal-power crossfade or immediate stop.
// Handle old sink: symmetric crossfade or immediate stop.
if crossfade_enabled {
if let Some(old) = old_sink {
// Trigger sample-level fade-out on Track A via TriggeredFadeOut.
// Calculate total fade samples from the measured actual_fade_secs.
let rate = state.current_sample_rate.load(Ordering::Relaxed);
let ch = state.current_channels.load(Ordering::Relaxed);
let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64;
if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) {
samples.store(fade_total.max(1), Ordering::SeqCst);
trigger.store(true, Ordering::SeqCst);
}
// Keep old sink alive until the fade completes + small margin,
// then drop it. No volume stepping needed — the fade-out runs
// at sample level inside the audio thread.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move {
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
// Duration = actual_fade_secs (Track A's measured remaining time),
// so the fade reaches exactly 0 when the source is exhausted.
const STEP_MS: u64 = 10;
let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32;
for i in (0..=total_steps).rev() {
let alive = {
let fo = fo_arc.lock().unwrap();
match fo.as_ref() {
Some(s) => {
// Equal-power cos curve: gain_a = cos(t · π/2)
// t goes 1→0 as i goes total_steps→0
let t = i as f32 / total_steps as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
s.set_volume(old_vol * gain);
true
}
None => false,
}
// MutexGuard dropped here before the await
};
if !alive { return; }
tokio::time::sleep(Duration::from_millis(STEP_MS)).await;
}
tokio::time::sleep(cleanup_dur).await;
if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop();
}
@@ -747,6 +973,10 @@ pub async fn audio_play(
state.crossfade_secs.clone(),
done_flag,
app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
);
Ok(())
@@ -815,14 +1045,22 @@ pub async fn audio_chain_preload(
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let done_next = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
// Use a dedicated counter for the chained source — it will be swapped into
// samples_played when the chained track becomes active.
let chain_counter = Arc::new(AtomicU64::new(0));
let target_rate = state.current_sample_rate.load(Ordering::Relaxed);
let built = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
).map_err(|e| e.to_string())?;
let source = built.source;
let duration_secs = built.duration_secs;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
@@ -847,6 +1085,7 @@ pub async fn audio_chain_preload(
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
sample_counter: chain_counter,
});
Ok(())
@@ -859,6 +1098,12 @@ pub async fn audio_chain_preload(
/// done flag is set AND `chained_info` has data, it swaps `done` to the
/// chained source's flag and transitions state — all without creating a new
/// task or changing the generation counter.
///
/// Key changes from the previous implementation:
/// • 100 ms tick (was 500 ms) — halves worst-case event latency
/// • Position from atomic sample counter (no wall-clock drift)
/// • Immediate `audio:track_switched` event at decoder boundary
/// • `audio:ended` only fires when no chained successor exists
fn spawn_progress_task(
gen: u64,
gen_counter: Arc<AtomicU64>,
@@ -868,6 +1113,10 @@ fn spawn_progress_task(
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
app: AppHandle,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
) {
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
@@ -875,7 +1124,8 @@ fn spawn_progress_task(
let mut current_done = initial_done;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
// 100 ms tick — tight enough for responsive UI, low enough CPU cost.
tokio::time::sleep(Duration::from_millis(100)).await;
if gen_counter.load(Ordering::SeqCst) != gen {
break;
@@ -884,24 +1134,40 @@ fn spawn_progress_task(
// ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit
// audio:playing for the new track, and continue the loop.
// audio:track_switched for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
current_done = info.source_done;
// Tracking was already updated in audio_play_gapless_chain;
// just update replay gain fields in case they differ.
// Swap the sample counter: the chained source's counter
// is already being incremented by CountingSource. Copy its
// current value into the shared samples_played so the
// progress calculation stays accurate.
let chained_samples = info.sample_counter.load(Ordering::Relaxed);
samples_played.store(chained_samples, Ordering::Relaxed);
// Update tracking state.
{
let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
// Reset play_started to now — the old track physically
// ended, the new one is now actively producing samples.
cur.duration_secs = info.duration_secs;
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
}
app.emit("audio:playing", info.duration_secs).ok();
// Record the gapless switch timestamp for ghost-command guard.
let switch_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
gapless_switch_at.store(switch_ts, Ordering::SeqCst);
// Emit the new track_switched event — this is immediate,
// not delayed by 500 ms like the old audio:playing was.
app.emit("audio:track_switched", info.duration_secs).ok();
near_end_ticks = 0;
continue;
}
@@ -910,9 +1176,26 @@ fn spawn_progress_task(
// the near-end logic below.
}
let (pos, dur, is_paused) = {
// ── Position from atomic sample counter ──────────────────────────
let rate = sample_rate_arc.load(Ordering::Relaxed) as f64;
let ch = channels_arc.load(Ordering::Relaxed) as f64;
let samples = samples_played.load(Ordering::Relaxed) as f64;
let divisor = (rate * ch).max(1.0);
let dur = {
let cur = current_arc.lock().unwrap();
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
cur.duration_secs
};
let is_paused = {
let cur = current_arc.lock().unwrap();
cur.paused_at.is_some()
};
let pos = if is_paused {
let cur = current_arc.lock().unwrap();
cur.paused_at.unwrap_or(0.0)
} else {
(samples / divisor).min(dur.max(0.001))
};
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
@@ -927,7 +1210,8 @@ fn spawn_progress_task(
if dur > end_threshold && pos >= dur - end_threshold {
near_end_ticks += 1;
if near_end_ticks >= 2 {
// At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms.
if near_end_ticks >= 10 {
gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok();
break;
@@ -982,6 +1266,20 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
// Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance.
{
let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst);
if switch_ms > 0 {
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
if now_ms.saturating_sub(switch_ms) < 500 {
return Ok(());
}
}
}
// Seeking far back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.9.0",
"version": "1.12.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -23,7 +23,8 @@
"decorations": true,
"transparent": false,
"visible": true,
"dragDropEnabled": false
"dragDropEnabled": false,
"devtools": false
}
],
"security": {
+47
View File
@@ -0,0 +1,47 @@
export interface LrclibLyrics {
syncedLyrics: string | null;
plainLyrics: string | null;
}
export interface LrcLine {
time: number; // seconds
text: string;
}
export async function fetchLyrics(
artist: string,
title: string,
album: string,
duration: number,
): Promise<LrclibLyrics | null> {
const params = new URLSearchParams({
artist_name: artist,
track_name: title,
album_name: album,
duration: Math.round(duration).toString(),
});
try {
const res = await fetch(`https://lrclib.net/api/get?${params}`);
if (!res.ok) return null;
const data = await res.json();
return {
syncedLyrics: data.syncedLyrics ?? null,
plainLyrics: data.plainLyrics ?? null,
};
} catch {
return null;
}
}
export function parseLrc(lrc: string): LrcLine[] {
const lines: LrcLine[] = [];
for (const line of lrc.split('\n')) {
const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/);
if (!match) continue;
const mins = parseInt(match[1], 10);
const secs = parseFloat(match[2]);
const text = match[3].trim();
lines.push({ time: mins * 60 + secs, text });
}
return lines.sort((a, b) => a.time - b.time);
}
+3 -2
View File
@@ -1,6 +1,7 @@
import axios from 'axios';
import md5 from 'md5';
import { useAuthStore } from '../store/authStore';
import { version } from '../../package.json';
// ─── Secure random salt ────────────────────────────────────────
function secureRandomSalt(): string {
@@ -13,7 +14,7 @@ function secureRandomSalt(): string {
function getAuthParams(username: string, password: string) {
const salt = secureRandomSalt();
const token = md5(password + salt);
return { u: username, t: token, s: salt, v: '1.16.1', c: 'psysonic', f: 'json' };
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
}
function getClient() {
@@ -166,7 +167,7 @@ export async function getAlbumList(
offset = 0,
extra: Record<string, unknown> = {}
): Promise<SubsonicAlbum[]> {
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, ...extra });
const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type, size, offset, _t: Date.now(), ...extra });
return data.albumList2?.album ?? [];
}
+2 -2
View File
@@ -59,8 +59,8 @@ export default function AlbumCard({ album }: AlbumCardProps) {
</div>
</div>
<div className="album-card-info">
<p className="album-card-title truncate" data-tooltip={album.name}>{album.name}</p>
<p className="album-card-artist truncate" data-tooltip={album.artist}>{album.artist}</p>
<p className="album-card-title truncate">{album.name}</p>
<p className="album-card-artist truncate">{album.artist}</p>
{album.year && <p className="album-card-year">{album.year}</p>}
</div>
</div>
+1 -1
View File
@@ -191,7 +191,7 @@ export default function AlbumHeader({
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 }}
style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }}
>
<Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')}
+3 -2
View File
@@ -86,6 +86,7 @@ export default function AlbumTrackList({
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,
starred: song.starred,
});
return (
@@ -143,7 +144,7 @@ export default function AlbumTrackList({
: (song.track ?? i + 1)}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
{hasVariousArtists && (
<div className="track-artist-cell">
@@ -155,7 +156,7 @@ export default function AlbumTrackList({
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)' }}
style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }}
>
<Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function ArtistCardLocal({ artist }: Props) {
)}
</div>
<div className="artist-card-info">
<span className="artist-card-name" data-tooltip={artist.name}>{artist.name}</span>
<span className="artist-card-name">{artist.name}</span>
{typeof artist.albumCount === 'number' && (
<span className="artist-card-meta">
{artist.albumCount} {artist.albumCount === 1 ? 'Album' : 'Alben'}
+5 -41
View File
@@ -3,45 +3,25 @@ import { SubsonicArtist } from '../api/subsonic';
import ArtistCardLocal from './ArtistCardLocal';
import { ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
interface Props {
title: string;
artists: SubsonicArtist[];
moreLink?: string;
moreText?: string;
onLoadMore?: () => Promise<void>;
}
export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMore }: Props) {
const { t } = useTranslation();
export default function ArtistRow({ title, artists, moreLink, moreText }: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate();
const [showLeft, setShowLeft] = useState(false);
const [showRight, setShowRight] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const loadingRef = useRef(false);
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setShowLeft(scrollLeft > 0);
setShowRight(scrollLeft < scrollWidth - clientWidth - 5);
// Auto-load trigger
if (onLoadMore && !loadingRef.current && scrollLeft > 0 && scrollLeft + clientWidth >= scrollWidth - 300) {
triggerLoadMore();
}
};
const triggerLoadMore = async () => {
if (!onLoadMore || loadingRef.current) return;
loadingRef.current = true;
setLoadingMore(true);
await onLoadMore();
setLoadingMore(false);
loadingRef.current = false;
};
useEffect(() => {
@@ -63,35 +43,19 @@ export default function ArtistRow({ title, artists, moreLink, moreText, onLoadMo
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{title}</h2>
<div className="album-row-nav">
<button
className={`nav-btn ${!showLeft ? 'disabled' : ''}`}
onClick={() => scroll('left')}
disabled={!showLeft}
>
<button className={`nav-btn ${!showLeft ? 'disabled' : ''}`} onClick={() => scroll('left')} disabled={!showLeft}>
<ChevronLeft size={20} />
</button>
<button
className={`nav-btn ${!showRight ? 'disabled' : ''}`}
onClick={() => scroll('right')}
disabled={!showRight}
>
<button className={`nav-btn ${!showRight ? 'disabled' : ''}`} onClick={() => scroll('right')} disabled={!showRight}>
<ChevronRight size={20} />
</button>
</div>
</div>
<div className="album-grid-wrapper">
<div className="album-grid" ref={scrollRef} onScroll={handleScroll}>
{artists.map(a => <ArtistCardLocal key={a.id} artist={a} />)}
{loadingMore && (
<div className="album-card-more" style={{ cursor: 'default' }}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<div className="spinner" style={{ width: 24, height: 24 }} />
</div>
<span style={{ fontSize: 13, fontWeight: 500 }}>{t('common.loadingMore')}</span>
</div>
)}
{!loadingMore && moreLink && (
{moreLink && (
<div className="album-card-more" onClick={() => navigate(moreLink)}>
<div style={{ padding: '1rem', background: 'var(--bg-app)', borderRadius: '50%' }}>
<ArrowRight size={24} />
+32 -9
View File
@@ -21,7 +21,7 @@ function sanitizeFilename(name: string): string {
export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong } = usePlayerStore();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride } = usePlayerStore();
const auth = useAuthStore();
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
@@ -53,6 +53,9 @@ export default function ContextMenu() {
const { type, item, queueIndex } = contextMenu;
const isStarred = (id: string, itemStarred?: string) =>
id in starredOverrides ? starredOverrides[id] : !!itemStarred;
const handleAction = async (action: () => void | Promise<void>) => {
closeContextMenu();
await action();
@@ -148,8 +151,13 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artist, song.artist))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
@@ -181,8 +189,13 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => navigate(`/artist/${album.artistId}`))}>
<User size={14} /> {t('contextMenu.goToArtist')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => star(album.id, 'album'))}>
<Star size={14} /> {t('contextMenu.favoriteAlbum')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(album.id, album.starred);
setStarredOverride(album.id, !starred);
return starred ? unstar(album.id, 'album') : star(album.id, 'album');
})}>
<Star size={14} fill={isStarred(album.id, album.starred) ? 'currentColor' : 'none'} />
{isStarred(album.id, album.starred) ? t('contextMenu.unfavoriteAlbum') : t('contextMenu.favoriteAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<Download size={14} /> {t('contextMenu.download')}
@@ -199,8 +212,13 @@ export default function ContextMenu() {
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => star(artist.id, 'artist'))}>
<Star size={14} /> {t('contextMenu.favoriteArtist')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(artist.id, artist.starred);
setStarredOverride(artist.id, !starred);
return starred ? unstar(artist.id, 'artist') : star(artist.id, 'artist');
})}>
<Star size={14} fill={isStarred(artist.id, artist.starred) ? 'currentColor' : 'none'} />
{isStarred(artist.id, artist.starred) ? t('contextMenu.unfavoriteArtist') : t('contextMenu.favoriteArtist')}
</div>
</>
);
@@ -224,8 +242,13 @@ export default function ContextMenu() {
<Disc3 size={14} /> {t('contextMenu.openAlbum')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => star(song.id, 'song'))}>
<Star size={14} /> {t('contextMenu.favorite')}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
return starred ? unstar(song.id, 'song') : star(song.id, 'song');
})}>
<Star size={14} fill={isStarred(song.id, song.starred) ? 'currentColor' : 'none'} />
{isStarred(song.id, song.starred) ? t('contextMenu.unfavorite') : t('contextMenu.favorite')}
</div>
{auth.lastfmSessionKey && (() => {
const loveKey = `${song.title}::${song.artist}`;
+4 -2
View File
@@ -1,4 +1,5 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface Props {
@@ -14,7 +15,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
return createPortal(
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
<img
@@ -23,6 +24,7 @@ export default function CoverLightbox({ src, alt, onClose }: Props) {
alt={alt}
onClick={e => e.stopPropagation()}
/>
</div>
</div>,
document.body
);
}
+15 -1
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import {
Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music
ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next';
@@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
@@ -241,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
>
{repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button>
<button
className={`fs-btn fs-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={14} />
</button>
</div>
</div>
+109
View File
@@ -0,0 +1,109 @@
import { useEffect, useRef, useState } from 'react';
import { usePlayerStore } from '../store/playerStore';
import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib';
import { useTranslation } from 'react-i18next';
import type { Track } from '../store/playerStore';
interface Props {
currentTrack: Track | null;
}
export default function LyricsPane({ currentTrack }: Props) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [syncedLines, setSyncedLines] = useState<LrcLine[] | null>(null);
const [plainLyrics, setPlainLyrics] = useState<string | null>(null);
const [notFound, setNotFound] = useState(false);
const [fetchedFor, setFetchedFor] = useState<string | null>(null);
const hasSynced = syncedLines !== null && syncedLines.length > 0;
const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0);
const lineRefs = useRef<(HTMLDivElement | null)[]>([]);
const prevActive = useRef(-1);
useEffect(() => {
if (!currentTrack || currentTrack.id === fetchedFor) return;
let cancelled = false;
setSyncedLines(null);
setPlainLyrics(null);
setNotFound(false);
setLoading(true);
lineRefs.current = [];
prevActive.current = -1;
fetchLyrics(
currentTrack.artist ?? '',
currentTrack.title,
currentTrack.album ?? '',
currentTrack.duration ?? 0,
).then(result => {
if (cancelled) return;
setLoading(false);
setFetchedFor(currentTrack.id);
if (!result || (!result.syncedLyrics && !result.plainLyrics)) {
setNotFound(true);
return;
}
if (result.syncedLyrics) {
const lines = parseLrc(result.syncedLyrics);
setSyncedLines(lines.length > 0 ? lines : null);
}
setPlainLyrics(result.plainLyrics);
}).catch(() => {
if (!cancelled) { setLoading(false); setNotFound(true); }
});
return () => { cancelled = true; };
}, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Reset when track changes
useEffect(() => {
setFetchedFor(null);
}, [currentTrack?.id]);
const activeIdx = hasSynced
? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1)
: -1;
useEffect(() => {
if (activeIdx < 0 || activeIdx === prevActive.current) return;
prevActive.current = activeIdx;
lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}, [activeIdx]);
if (!currentTrack) {
return (
<div className="lyrics-pane-empty">
<p className="lyrics-status">{t('player.lyricsNotFound')}</p>
</div>
);
}
return (
<div className="lyrics-pane">
{loading && <p className="lyrics-status">{t('player.lyricsLoading')}</p>}
{notFound && !loading && <p className="lyrics-status">{t('player.lyricsNotFound')}</p>}
{hasSynced && (
<div className="lyrics-synced">
{syncedLines!.map((line, i) => (
<div
key={i}
ref={el => { lineRefs.current[i] = el; }}
className={`lyrics-line${i === activeIdx ? ' active' : ''}`}
>
{line.text || '\u00A0'}
</div>
))}
</div>
)}
{!hasSynced && plainLyrics && (
<div className="lyrics-plain">
{plainLyrics.split('\n').map((line, i) => (
<p key={i} className="lyrics-plain-line">{line || '\u00A0'}</p>
))}
</div>
)}
</div>
);
}
+21 -7
View File
@@ -1,7 +1,8 @@
import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music,
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart
Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal
} from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
@@ -11,6 +12,7 @@ import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -23,11 +25,14 @@ export default function PlayerBar() {
const { t } = useTranslation();
const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const {
currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
} = usePlayerStore();
const { lastfmSessionKey } = useAuthStore();
@@ -74,7 +79,6 @@ export default function PlayerBar() {
<div className="player-track-meta">
<div
className="player-track-name"
data-tooltip={currentTrack?.title ?? ''}
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
@@ -82,7 +86,6 @@ export default function PlayerBar() {
</div>
<div
className="player-track-artist"
data-tooltip={currentTrack?.artist ?? ''}
style={{ cursor: currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
>
@@ -116,7 +119,7 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={isPlaying ? t('player.pause') : t('player.play')}
>
{isPlaying ? <Pause size={22} /> : <Play size={22} fill="currentColor" />}
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" />}
</button>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} />
@@ -141,6 +144,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span>
</div>
{/* Lyrics Button */}
<button
className={`player-btn player-btn-sm ${activeTab === 'lyrics' && isQueueVisible ? 'active' : ''}`}
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
aria-label={t('player.lyrics')}
data-tooltip={t('player.lyrics')}
>
<MicVocal size={15} />
</button>
{/* EQ Button */}
<button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
@@ -175,8 +188,8 @@ export default function PlayerBar() {
/>
</div>
{/* EQ Popup */}
{eqOpen && (
{/* EQ Popup — rendered via portal to avoid backdrop-filter containing-block issue */}
{eqOpen && createPortal(
<>
<div className="eq-popup-backdrop" onClick={() => setEqOpen(false)} />
<div className="eq-popup">
@@ -188,7 +201,8 @@ export default function PlayerBar() {
</div>
<Equalizer />
</div>
</>
</>,
document.body
)}
</footer>
+31 -5
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -139,6 +141,9 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
@@ -329,16 +334,14 @@ export default function QueuePanel() {
)}
</div>
<div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
<h3 className="truncate">{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.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>{currentTrack.album}</div>
@@ -350,7 +353,8 @@ export default function QueuePanel() {
</div>
)}
<div className="queue-toolbar">
{activeTab === 'queue' ? (<>
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
@@ -474,6 +478,28 @@ export default function QueuePanel() {
})
)}
</div>
</>) : (
<LyricsPane currentTrack={currentTrack} />
)}
<div className="queue-tab-bar">
<button
className={`queue-tab-btn${activeTab === 'queue' ? ' active' : ''}`}
onClick={() => setTab('queue')}
aria-label={t('queue.title')}
>
<ListMusic size={14} />
{t('queue.title')}
</button>
<button
className={`queue-tab-btn${activeTab === 'lyrics' ? ' active' : ''}`}
onClick={() => setTab('lyrics')}
aria-label={t('player.lyrics')}
>
<MicVocal size={14} />
{t('player.lyrics')}
</button>
</div>
{saveModalOpen && (
<SavePlaylistModal
+164 -137
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react';
import { useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef {
id: string;
@@ -9,69 +10,86 @@ interface ThemeDef {
}
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
group: 'Games',
themes: [
{ id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'nightcity-2077', label: 'NightCity 2077', bg: '#050505', card: '#000000', accent: '#FCEE0A' },
{ id: 'v-tactical', label: 'V-Tactical', bg: '#161c22', card: '#090c0e', accent: '#ff8a00' },
],
},
{
group: 'Movies',
themes: [
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'order-of-the-phoenix', label: 'Order of the Phoenix', bg: '#181818', card: '#0a0a0a', accent: '#e63900' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' },
],
},
{
group: 'Open Source Classics',
themes: [
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
],
},
{
group: 'Operating Systems',
themes: [
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'aero-glass', label: 'W7', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'w98', label: 'W98', bg: '#008080', card: '#c0c0c0', accent: '#000080' },
{ id: 'luna-teal', label: 'WXP', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Psysonic Themes',
themes: [
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
],
},
{
group: 'Psysonic Themes — Mediaplayer',
themes: [
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'cobalt-media', label: 'Cobalt Media', bg: '#3a62a5', card: '#000000', accent: '#45ff00' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'navy-jukebox', label: 'Navy Jukebox', bg: '#d4d8db', card: '#001358', accent: '#0070a0' },
{ id: 'onyx-cinema', label: 'Onyx Cinema', bg: '#141414', card: '#000000', accent: '#00aaff' },
{ id: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'wnamp', label: 'WnAmp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
],
},
{
group: 'Betriebssysteme',
group: 'Series',
themes: [
{ id: 'cupertino-light', label: 'Cupertino Light', bg: '#ffffff', card: '#f2f2f7', accent: '#0071e3' },
{ id: 'cupertino-dark', label: 'Cupertino Dark', bg: '#1e1e1f', card: '#2d2d2f', accent: '#007aff' },
{ id: 'aero-glass', label: 'Aero Glass', bg: '#cddbed', card: '#1d4268', accent: '#1878e8' },
{ id: 'luna-teal', label: 'Luna Teal', bg: '#ece9d8', card: '#0055e5', accent: '#3c9d29' },
],
},
{
group: 'Catppuccin',
themes: [
{ id: 'mocha', label: 'Mocha', bg: '#1e1e2e', card: '#313244', accent: '#cba6f7' },
{ id: 'macchiato', label: 'Macchiato', bg: '#24273a', card: '#363a4f', accent: '#c6a0f6' },
{ id: 'frappe', label: 'Frappé', bg: '#303446', card: '#414559', accent: '#ca9ee6' },
{ id: 'latte', label: 'Latte', bg: '#eff1f5', card: '#ccd0da', accent: '#8839ef' },
],
},
{
group: 'Nord',
themes: [
{ id: 'nord', label: 'Polar Night', bg: '#3b4252', card: '#434c5e', accent: '#88c0d0' },
{ id: 'nord-snowstorm', label: 'Snowstorm', bg: '#e5e9f0', card: '#eceff4', accent: '#5e81ac' },
{ id: 'nord-frost', label: 'Frost', bg: '#1e2d3d', card: '#243447', accent: '#88c0d0' },
{ id: 'nord-aurora', label: 'Aurora', bg: '#3b4252', card: '#434c5e', accent: '#b48ead' },
],
},
{
group: 'Retro',
themes: [
{ id: 'gruvbox-dark-hard', label: 'Dark Hard', bg: '#1d2021', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-medium', label: 'Dark Medium', bg: '#282828', card: '#3c3836', accent: '#fabd2f' },
{ id: 'gruvbox-dark-soft', label: 'Dark Soft', bg: '#32302f', card: '#45403d', accent: '#fabd2f' },
{ id: 'gruvbox-light-hard', label: 'Light Hard', bg: '#f9f5d7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-medium', label: 'Light Medium', bg: '#fbf1c7', card: '#f2e5bc', accent: '#b57614' },
{ id: 'gruvbox-light-soft', label: 'Light Soft', bg: '#f2e5bc', card: '#ebdbb2', accent: '#b57614' },
],
},
{
group: 'Tokyo Night',
themes: [
{ id: 'tokyo-night', label: 'Standard', bg: '#1a1b26', card: '#24283b', accent: '#7aa2f7' },
{ id: 'tokyo-night-storm', label: 'Storm', bg: '#24283b', card: '#2f334d', accent: '#7aa2f7' },
{ id: 'tokyo-night-light', label: 'Light', bg: '#d5d6db', card: '#e9e9ec', accent: '#34548a' },
{ id: 'ice-and-fire', label: 'A Theme of Ice and Fire', bg: '#121820', card: '#05070a', accent: '#70a1ff' },
{ id: 'doh-matic', label: "D'oh-matic", bg: '#FFFDF0', card: '#FFD90F', accent: '#1F75FE' },
{ id: 'heisenberg', label: 'Heisenberg', bg: '#1a1d1a', card: '#0a0c0a', accent: '#3fe0ff' },
],
},
];
@@ -82,93 +100,102 @@ interface Props {
}
export default function ThemePicker({ value, onChange }: Props) {
const initialOpen = THEME_GROUPS.find(g => g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group;
const [openGroup, setOpenGroup] = useState<string | null>(initialOpen);
const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{THEME_GROUPS.map(({ group, themes }) => (
<div key={group}>
<div style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-muted)',
marginBottom: '10px',
}}>
{group}
<div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => {
const isOpen = openGroup === group;
const hasActive = themes.some(t => t.id === value);
return (
<div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
<button className="theme-accordion-header" onClick={() => toggle(group)}>
<span>
{group}
{hasActive && !isOpen && (
<span className="theme-accordion-active-dot" />
)}
</span>
<ChevronDown size={15} className="theme-accordion-chevron" />
</button>
{isOpen && (
<div className="theme-accordion-content">
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
<div style={{ background: t.bg, height: '55%' }} />
<div style={{ background: t.card, height: '20%' }} />
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(72px, 1fr))',
gap: '10px',
}}>
{themes.map((t) => {
const isActive = value === t.id;
return (
<button
key={t.id}
onClick={() => onChange(t.id)}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '6px',
}}
>
<div style={{
width: '100%',
height: '46px',
borderRadius: '8px',
overflow: 'hidden',
outline: isActive ? '2px solid var(--accent)' : '2px solid transparent',
outlineOffset: '2px',
position: 'relative',
boxShadow: isActive ? '0 0 8px var(--accent-glow, rgba(0,0,0,0.2))' : '0 1px 3px rgba(0,0,0,0.3)',
transition: 'outline-color 0.15s, box-shadow 0.15s',
}}>
{/* main bg */}
<div style={{ background: t.bg, height: '55%' }} />
{/* card tone */}
<div style={{ background: t.card, height: '20%' }} />
{/* accent bar */}
<div style={{ background: t.accent, height: '25%' }} />
{isActive && (
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
width: '14px',
height: '14px',
borderRadius: '50%',
background: t.accent,
border: '1.5px solid rgba(255,255,255,0.7)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Check size={8} strokeWidth={3} color="white" />
</div>
)}
</div>
<span style={{
fontSize: '11px',
color: isActive ? 'var(--text-primary)' : 'var(--text-secondary)',
fontWeight: isActive ? 600 : 400,
textAlign: 'center',
lineHeight: 1.2,
wordBreak: 'break-word',
}}>
{t.label}
</span>
</button>
);
})}
</div>
</div>
))}
);
})}
</div>
);
}
+9 -2
View File
@@ -13,6 +13,9 @@ export default function TooltipPortal() {
const boxRef = useRef<HTMLDivElement>(null);
const [style, setStyle] = useState<React.CSSProperties>({ opacity: 0 });
const tooltipRef = useRef<TooltipState | null>(null);
tooltipRef.current = tooltip;
useEffect(() => {
const onOver = (e: MouseEvent) => {
const target = (e.target as HTMLElement).closest('[data-tooltip]') as HTMLElement | null;
@@ -26,15 +29,19 @@ export default function TooltipPortal() {
wrap: target.hasAttribute('data-tooltip-wrap'),
});
};
const onOut = (e: MouseEvent) => {
const onOut = () => setTooltip(null);
const onMove = (e: MouseEvent) => {
if (!tooltipRef.current) return;
const target = (e.target as HTMLElement).closest('[data-tooltip]');
if (target) setTooltip(null);
if (!target) setTooltip(null);
};
document.addEventListener('mouseover', onOver);
document.addEventListener('mouseout', onOut);
document.addEventListener('mousemove', onMove, { passive: true });
return () => {
document.removeEventListener('mouseover', onOver);
document.removeEventListener('mouseout', onOut);
document.removeEventListener('mousemove', onMove);
};
}, []);
+48 -8
View File
@@ -30,7 +30,9 @@ const enTranslation = {
mostPlayed: 'Most Played',
discover: 'Discover',
loadMore: 'Load More',
discoverMore: 'Discover More'
discoverMore: 'Discover More',
discoverArtists: 'Discover Artists',
discoverArtistsMore: 'All Artists'
},
hero: {
eyebrow: 'Featured Album',
@@ -62,6 +64,8 @@ const enTranslation = {
goToArtist: 'Go to Artist',
readMore: 'Read more',
showLess: 'Show less',
genreInfo: 'Genre',
trackInfo: 'Track Info',
},
contextMenu: {
playNow: 'Play Now',
@@ -74,6 +78,9 @@ const enTranslation = {
favorite: 'Favorite',
favoriteArtist: 'Favorite Artist',
favoriteAlbum: 'Favorite Album',
unfavorite: 'Remove from Favorites',
unfavoriteArtist: 'Remove Artist from Favorites',
unfavoriteAlbum: 'Remove Album from Favorites',
removeFromQueue: 'Remove from Queue',
openAlbum: 'Open Album',
goToArtist: 'Go to Artist',
@@ -518,7 +525,10 @@ const enTranslation = {
repeatOne: 'One',
progress: 'Song Progress',
volume: 'Volume',
toggleQueue: 'Toggle Queue'
toggleQueue: 'Toggle Queue',
lyrics: 'Lyrics',
lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track',
}
};
@@ -551,7 +561,9 @@ const deTranslation = {
mostPlayed: 'Meistgehört',
discover: 'Entdecken',
loadMore: 'Mehr laden',
discoverMore: 'Mehr entdecken'
discoverMore: 'Mehr entdecken',
discoverArtists: 'Künstler entdecken',
discoverArtistsMore: 'Alle Künstler'
},
hero: {
eyebrow: 'Album des Augenblicks',
@@ -583,6 +595,8 @@ const deTranslation = {
goToArtist: 'Zum Künstler',
readMore: 'Mehr lesen',
showLess: 'Weniger anzeigen',
genreInfo: 'Genre',
trackInfo: 'Track-Info',
},
contextMenu: {
playNow: 'Direkt abspielen',
@@ -595,6 +609,9 @@ const deTranslation = {
favorite: 'Favorisieren',
favoriteArtist: 'Künstler favorisieren',
favoriteAlbum: 'Album favorisieren',
unfavorite: 'Aus Favoriten entfernen',
unfavoriteArtist: 'Künstler aus Favoriten entfernen',
unfavoriteAlbum: 'Album aus Favoriten entfernen',
removeFromQueue: 'Diesen Song entfernen',
openAlbum: 'Album öffnen',
goToArtist: 'Zum Künstler',
@@ -1039,7 +1056,10 @@ const deTranslation = {
repeatOne: 'Einen',
progress: 'Songfortschritt',
volume: 'Lautstärke',
toggleQueue: 'Warteschlange umschalten'
toggleQueue: 'Warteschlange umschalten',
lyrics: 'Lyrics',
lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
}
};
@@ -1072,7 +1092,9 @@ const frTranslation = {
mostPlayed: 'Les plus écoutés',
discover: 'Découvrir',
loadMore: 'Charger plus',
discoverMore: 'Découvrir plus'
discoverMore: 'Découvrir plus',
discoverArtists: 'Découvrir des artistes',
discoverArtistsMore: 'Tous les artistes'
},
hero: {
eyebrow: 'Album en vedette',
@@ -1104,6 +1126,8 @@ const frTranslation = {
goToArtist: "Aller à l'artiste",
readMore: 'Lire la suite',
showLess: 'Réduire',
genreInfo: 'Genre',
trackInfo: 'Infos piste',
},
contextMenu: {
playNow: 'Lire maintenant',
@@ -1116,6 +1140,9 @@ const frTranslation = {
favorite: 'Favori',
favoriteArtist: 'Artiste favori',
favoriteAlbum: 'Album favori',
unfavorite: 'Retirer des favoris',
unfavoriteArtist: 'Retirer l\'artiste des favoris',
unfavoriteAlbum: 'Retirer l\'album des favoris',
removeFromQueue: 'Retirer de la file',
openAlbum: 'Ouvrir l\'album',
goToArtist: 'Aller à l\'artiste',
@@ -1560,7 +1587,10 @@ const frTranslation = {
repeatOne: 'Un',
progress: 'Progression',
volume: 'Volume',
toggleQueue: 'Afficher/masquer la file'
toggleQueue: 'Afficher/masquer la file',
lyrics: 'Paroles',
lyricsLoading: 'Chargement des paroles…',
lyricsNotFound: 'Aucune parole trouvée pour ce titre',
}
};
@@ -1593,7 +1623,9 @@ const nlTranslation = {
mostPlayed: 'Meest gespeeld',
discover: 'Ontdekken',
loadMore: 'Meer laden',
discoverMore: 'Meer ontdekken'
discoverMore: 'Meer ontdekken',
discoverArtists: 'Artiesten ontdekken',
discoverArtistsMore: 'Alle artiesten'
},
hero: {
eyebrow: 'Uitgelicht album',
@@ -1625,6 +1657,8 @@ const nlTranslation = {
goToArtist: 'Naar artiest',
readMore: 'Meer lezen',
showLess: 'Minder tonen',
genreInfo: 'Genre',
trackInfo: 'Trackinfo',
},
contextMenu: {
playNow: 'Nu afspelen',
@@ -1637,6 +1671,9 @@ const nlTranslation = {
favorite: 'Favoriet',
favoriteArtist: 'Favoriete artiest',
favoriteAlbum: 'Favoriet album',
unfavorite: 'Verwijderen uit favorieten',
unfavoriteArtist: 'Artiest uit favorieten verwijderen',
unfavoriteAlbum: 'Album uit favorieten verwijderen',
removeFromQueue: 'Uit wachtrij verwijderen',
openAlbum: 'Album openen',
goToArtist: 'Naar artiest',
@@ -2081,7 +2118,10 @@ const nlTranslation = {
repeatOne: 'Één',
progress: 'Nummervoortgang',
volume: 'Volume',
toggleQueue: 'Wachtrij in-/uitschakelen'
toggleQueue: 'Wachtrij in-/uitschakelen',
lyrics: 'Songtekst',
lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
}
};
+5 -1
View File
@@ -29,6 +29,7 @@ export default function AlbumDetail() {
const playTrack = usePlayerStore(s => s.playTrack);
const enqueue = usePlayerStore(s => s.enqueue);
const openContextMenu = usePlayerStore(s => s.openContextMenu);
const starredOverrides = usePlayerStore(s => s.starredOverrides);
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
@@ -218,7 +219,10 @@ export default function AlbumDetail() {
hoveredSongId={hoveredSongId}
setHoveredSongId={setHoveredSongId}
ratings={ratings}
starredSongs={starredSongs}
starredSongs={new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
])}
onPlaySong={handlePlaySong}
onRate={handleRate}
onToggleSongStar={toggleSongStar}
+1 -1
View File
@@ -341,7 +341,7 @@ export default function ArtistDetail() {
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,
year: song.year, bitRate: song.bitRate, suffix: song.suffix, userRating: song.userRating, starred: song.starred,
};
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
+2 -2
View File
@@ -87,7 +87,7 @@ export default function Favorites() {
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, starred: song.starred,
};
return (
<div
@@ -106,7 +106,7 @@ export default function Favorites() {
{i + 1}
</div>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span
+53 -27
View File
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import Hero from '../components/Hero';
import AlbumRow from '../components/AlbumRow';
import { getAlbumList, SubsonicAlbum } from '../api/subsonic';
import { getAlbumList, getArtists, SubsonicAlbum, SubsonicArtist } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
export default function Home() {
const [starred, setStarred] = useState<SubsonicAlbum[]>([]);
@@ -10,20 +11,29 @@ export default function Home() {
const [random, setRandom] = useState<SubsonicAlbum[]>([]);
const [heroAlbums, setHeroAlbums] = useState<SubsonicAlbum[]>([]);
const [mostPlayed, setMostPlayed] = useState<SubsonicAlbum[]>([]);
const [randomArtists, setRandomArtists] = useState<SubsonicArtist[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([
getAlbumList('starred', 12).catch(() => []),
getAlbumList('newest', 12).catch(() => []),
getAlbumList('random', 20).catch(() => []), // fetch 20 — split between Hero and Discover
getAlbumList('random', 20).catch(() => []),
getAlbumList('frequent', 12).catch(() => []),
]).then(([s, n, r, f]) => {
getArtists().catch(() => []),
]).then(([s, n, r, f, artists]) => {
setStarred(s);
setRecent(n);
setHeroAlbums(r.slice(0, 8));
setRandom(r.slice(8));
setMostPlayed(f);
// Pick 16 random artists via Fisher-Yates shuffle
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomArtists(shuffled.slice(0, 16));
setLoading(false);
}).catch(() => setLoading(false));
}, []);
@@ -35,17 +45,15 @@ export default function Home() {
) => {
try {
const more = await getAlbumList(type, 12, currentList.length);
// Ensure we don't append duplicates if the API returns them
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 { t } = useTranslation();
const navigate = useNavigate();
return (
<div className="animate-fade-in">
@@ -58,31 +66,49 @@ export default function Home() {
</div>
) : (
<>
<AlbumRow
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
/>
{randomArtists.length > 0 && (
<section className="album-row-section">
<div className="album-row-header">
<h2 className="section-title" style={{ marginBottom: 0 }}>{t('home.discoverArtists')}</h2>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{randomArtists.map(a => (
<button key={a.id} className="artist-ext-link" onClick={() => navigate(`/artist/${a.id}`)}>
{a.name}
</button>
))}
<button className="artist-ext-link" onClick={() => navigate('/artists')}
style={{ opacity: 0.6 }}>
{t('home.discoverArtistsMore')}
</button>
</div>
</section>
)}
{starred.length > 0 && (
<AlbumRow
title={t('home.starred')}
albums={starred}
<AlbumRow
title={t('home.starred')}
albums={starred}
onLoadMore={() => loadMore('starred', starred, setStarred)}
moreText={t('home.loadMore')}
moreText={t('home.loadMore')}
/>
)}
<AlbumRow
title={t('home.recent')}
albums={recent}
onLoadMore={() => loadMore('newest', recent, setRecent)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.mostPlayed')}
albums={mostPlayed}
<AlbumRow
title={t('home.mostPlayed')}
albums={mostPlayed}
onLoadMore={() => loadMore('frequent', mostPlayed, setMostPlayed)}
moreText={t('home.loadMore')}
/>
<AlbumRow
title={t('home.discover')}
albums={random}
onLoadMore={() => loadMore('random', random, setRandom)}
moreText={t('home.discoverMore')}
moreText={t('home.loadMore')}
/>
</>
)}
+163 -70
View File
@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink } from 'lucide-react';
import { Music, Star, ExternalLink, MicVocal } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
getAlbum, getArtistInfo,
@@ -107,30 +108,49 @@ const EQBars = memo(function EQBars({ isPlaying }: { isPlaying: boolean }) {
// ─── Tag Cloud ────────────────────────────────────────────────────────────────
interface TagCloudProps {
genre?: string;
year?: number;
similarArtists: Array<{ id: string; name: string }>;
onArtistClick: (id: string) => void;
}
function TagCloud({ genre, year, similarArtists, onArtistClick }: TagCloudProps) {
function strHash(s: string): number {
let h = 0;
for (const c of s) h = (h * 31 + c.charCodeAt(0)) & 0xffff;
return h;
}
function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
const { t } = useTranslation();
const hasTags = genre || year || similarArtists.length > 0;
if (!hasTags) return null;
if (similarArtists.length === 0) return null;
const getTagStyle = (name: string, idx: number): React.CSSProperties => {
const h = strHash(name);
const sizePool = [10, 11, 12, 13, 14, 15, 16];
const size = sizePool[(h + idx * 7) % sizePool.length];
const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
const pad = size >= 15 ? '5px 10px' : '4px 8px';
const opacity = 0.6 + ((h % 5) * 0.08);
const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1];
const ty = verticals[(h + idx * 4) % verticals.length];
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
};
return (
<div className="np-tag-cloud">
{genre && <span className="np-tag np-tag-accent">{genre}</span>}
{year && <span className="np-tag">{year}</span>}
{similarArtists.slice(0, 6).map(a => (
<span
key={a.id}
className="np-tag np-tag-clickable"
onClick={() => onArtistClick(a.id)}
data-tooltip={t('nowPlaying.goToArtist')}
>
{a.name}
</span>
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => (
<div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '14px' } : undefined}>
{row.map((a, i) => (
<span
key={a.id}
className="np-tag np-tag-clickable"
style={getTagStyle(a.name, rowIdx * 3 + i)}
onClick={() => onArtistClick(a.id)}
data-tooltip={t('nowPlaying.goToArtist')}
>
{a.name}
</span>
))}
</div>
))}
</div>
);
@@ -165,6 +185,53 @@ const NpBg = memo(function NpBg({ url }: { url: string }) {
);
});
// ─── Album Tracklist ──────────────────────────────────────────────────────────
interface NpTrackListProps {
albumTracks: SubsonicSong[];
currentTrackId: string;
album: string;
albumId?: string;
onNavigate: (path: string) => void;
}
const NpTrackList = memo(function NpTrackList({ albumTracks, currentTrackId, album, albumId, onNavigate }: NpTrackListProps) {
const { t } = useTranslation();
if (albumTracks.length === 0) return null;
return (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{album}</em></h3>
{albumId && (
<button className="np-card-link" onClick={() => onNavigate(`/album/${albumId}`)}>
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-album-tracklist">
{albumTracks.map(track => {
const isActive = track.id === currentTrackId;
return (
<div key={track.id}
className={`np-album-track${isActive ? ' active' : ''}`}
onClick={() => albumId && onNavigate(`/album/${albumId}`)}
>
<span className="np-album-track-num">
{isActive
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
: track.track ?? '—'
}
</span>
<span className="np-album-track-title truncate">{track.title}</span>
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
</div>
);
})}
</div>
</div>
);
});
// ─── Main Page ────────────────────────────────────────────────────────────────
export default function NowPlaying() {
@@ -173,14 +240,12 @@ export default function NowPlaying() {
const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying);
const setQueueVisible = usePlayerStore(s => s.setQueueVisible);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
// Hide queue panel while on this page, restore on leave
useEffect(() => {
const wasVisible = usePlayerStore.getState().isQueueVisible;
if (wasVisible) setQueueVisible(false);
return () => { if (wasVisible) setQueueVisible(true); };
}, [setQueueVisible]);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
// Extra song metadata
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
@@ -221,6 +286,42 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Ambilight — sample 8 zones (4 corners + 4 edge midpoints)
const [ambilightColors, setAmbilightColors] = useState({
tl: '0,0,0', tc: '0,0,0', tr: '0,0,0',
ml: '0,0,0', mr: '0,0,0',
bl: '0,0,0', bc: '0,0,0', br: '0,0,0',
});
useEffect(() => {
if (!resolvedCover) return;
const img = new Image();
img.onload = () => {
const S = 30;
const canvas = document.createElement('canvas');
canvas.width = S; canvas.height = S;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.drawImage(img, 0, 0, S, S);
const data = ctx.getImageData(0, 0, S, S).data;
const t = Math.floor(S * 0.25), m = Math.floor(S * 0.5), b2 = Math.floor(S * 0.75);
const avg = (x0: number, y0: number, x1: number, y1: number) => {
let r = 0, g = 0, b = 0, n = 0;
for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) {
const i = (y * S + x) * 4;
r += data[i]; g += data[i+1]; b += data[i+2]; n++;
}
return `${Math.round(r/n)},${Math.round(g/n)},${Math.round(b/n)}`;
};
setAmbilightColors({
tl: avg(0, 0, t, t), tc: avg(t, 0, b2, t), tr: avg(b2, 0, S, t),
ml: avg(0, t, t, b2), mr: avg(b2, t, S, b2),
bl: avg(0, b2, t, S), bc: avg(t, b2, b2, S), br: avg(b2, b2, S, S),
});
};
img.src = resolvedCover;
}, [resolvedCover]);
const similarArtists = artistInfo?.similarArtist ?? [];
return (
@@ -233,17 +334,10 @@ export default function NowPlaying() {
{/* ── Hero Card ── */}
<div className="np-hero-card">
{/* Left: cover + meta info */}
{/* Left: meta info */}
<div className="np-hero-left">
<div className="np-hero-cover-wrap">
{resolvedCover && <img src={resolvedCover} alt="" className="np-cover-glow" aria-hidden />}
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" />
: <div className="np-cover np-cover-fallback"><Music size={52} /></div>
}
</div>
<div className="np-hero-info">
<div className="np-title">{currentTrack.title}</div>
<div className="np-title" style={{ color: 'var(--accent)' }}>{currentTrack.title}</div>
<div className="np-artist-album">
<span className="np-link"
onClick={() => currentTrack.artistId && navigate(`/artist/${currentTrack.artistId}`)}
@@ -267,17 +361,42 @@ export default function NowPlaying() {
>
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button>
<button
className="np-star-btn"
onClick={() => { if (!isQueueVisible) toggleQueue(); showLyrics(); }}
data-tooltip={t('player.lyrics')}
style={{ color: activeTab === 'lyrics' && isQueueVisible ? 'var(--accent)' : 'white' }}
>
<MicVocal size={17} />
</button>
</div>
</div>
</div>
{/* Center: EQ bars */}
<EQBars isPlaying={isPlaying} />
{/* Center: cover */}
<div className="np-hero-cover-wrap">
<div style={{
position: 'absolute', inset: '-20px', zIndex: 0,
background: `
radial-gradient(circle at 0% 0%, rgba(${ambilightColors.tl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 0%, rgba(${ambilightColors.tc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 0%, rgba(${ambilightColors.tr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 50%, rgba(${ambilightColors.ml},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 50%, rgba(${ambilightColors.mr},0.85) 0%, transparent 55%),
radial-gradient(circle at 0% 100%, rgba(${ambilightColors.bl},0.85) 0%, transparent 55%),
radial-gradient(circle at 50% 100%, rgba(${ambilightColors.bc},0.85) 0%, transparent 55%),
radial-gradient(circle at 100% 100%, rgba(${ambilightColors.br},0.85) 0%, transparent 55%)
`,
filter: 'blur(28px)',
}} />
{resolvedCover
? <img src={resolvedCover} alt="" className="np-cover" style={{ position: 'relative', zIndex: 1 }} />
: <div className="np-cover np-cover-fallback" style={{ position: 'relative', zIndex: 1 }}><Music size={52} /></div>
}
</div>
{/* Right: tag cloud */}
<TagCloud
genre={songMeta?.genre}
year={currentTrack.year}
similarArtists={similarArtists}
onArtistClick={id => navigate(`/artist/${id}`)}
/>
@@ -314,39 +433,13 @@ export default function NowPlaying() {
</div>
)}
{/* ── From this Album ── */}
{albumTracks.length > 0 && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">{t('nowPlaying.fromAlbum')}: <em style={{ fontStyle: 'normal', color: 'rgba(255,255,255,0.6)' }}>{currentTrack.album}</em></h3>
{currentTrack.albumId && (
<button className="np-card-link" onClick={() => navigate(`/album/${currentTrack.albumId}`)}>
{t('nowPlaying.viewAlbum')} <ExternalLink size={12} />
</button>
)}
</div>
<div className="np-album-tracklist">
{albumTracks.map(track => {
const isActive = track.id === currentTrack.id;
return (
<div key={track.id}
className={`np-album-track${isActive ? ' active' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
>
<span className="np-album-track-num">
{isActive
? <Star size={10} fill="var(--accent)" color="var(--accent)" />
: track.track ?? '—'
}
</span>
<span className="np-album-track-title truncate">{track.title}</span>
<span className="np-album-track-dur">{formatTime(track.duration)}</span>
</div>
);
})}
</div>
</div>
)}
<NpTrackList
albumTracks={albumTracks}
currentTrackId={currentTrack.id}
album={currentTrack.album}
albumId={currentTrack.albumId}
onNavigate={stableNavigate}
/>
</>
) : (
<div className="np-empty-state">
+8 -8
View File
@@ -210,7 +210,7 @@ export default function RandomMix() {
overflow: 'hidden',
}}>
{/* Left: Blacklist */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.filterPanelTitle')}
</div>
@@ -291,7 +291,7 @@ export default function RandomMix() {
</div>
{/* Right: Genre Mix */}
<div style={{ background: 'var(--bg-elevated)', padding: '1rem 1.25rem' }}>
<div style={{ background: 'var(--bg-card)', padding: '1rem 1.25rem' }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em', color: 'var(--text-muted)', marginBottom: '0.75rem' }}>
{t('randomMix.genreMixTitle')}
</div>
@@ -342,7 +342,7 @@ export default function RandomMix() {
{genreMixSongs.map(song => (
<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'); }}
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, starred: song.starred }, '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 } }));
@@ -351,7 +351,7 @@ export default function RandomMix() {
<button className="btn btn-ghost" style={{ padding: 4 }} onClick={e => { e.stopPropagation(); playTrack(song, genreMixSongs); }}>
<Play size={14} fill="currentColor" />
</button>
<div className="track-info"><span className="track-title" data-tooltip={song.title}>{song.title}</span></div>
<div className="track-info"><span className="track-title">{song.title}</span></div>
<div className="track-artist-cell"><span className="track-artist">{song.artist}</span></div>
<div className="track-info"><span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span></div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{song.genre ?? '—'}</div>
@@ -391,7 +391,7 @@ export default function RandomMix() {
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 };
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, starred: song.starred };
setContextMenuSongId(song.id);
openContextMenu(e.clientX, e.clientY, track, 'song');
}}
@@ -415,15 +415,15 @@ export default function RandomMix() {
</button>
<div className="track-info">
<span className="track-title" data-tooltip={song.title}>{song.title}</span>
<span className="track-title">{song.title}</span>
</div>
<div className="track-artist-cell">
<span className="track-artist" data-tooltip={song.artist}>{song.artist}</span>
<span className="track-artist">{song.artist}</span>
</div>
<div className="track-info">
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }} data-tooltip={song.album}>{song.album}</span>
<span className="track-title" style={{ fontSize: '0.85rem', color: 'var(--subtext0)' }}>{song.album}</span>
</div>
{(() => {
+13 -16
View File
@@ -212,11 +212,11 @@ export default function Settings() {
};
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'playback', label: t('settings.tabPlayback'), icon: <Play size={15} /> },
{ id: 'library', label: t('settings.tabLibrary'), icon: <Shuffle size={15} /> },
{ id: 'appearance', label: t('settings.tabAppearance'), icon: <Palette size={15} /> },
{ id: 'shortcuts', label: t('settings.tabShortcuts'), icon: <Keyboard size={15} /> },
{ id: 'server', label: t('settings.tabServer'), icon: <Server size={15} /> },
{ id: 'about', label: t('settings.tabAbout'), icon: <Info size={15} /> },
];
@@ -331,11 +331,8 @@ export default function Settings() {
{/* Gapless */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<div style={{ fontWeight: 500 }}>
{t('settings.gapless')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div>
@@ -468,16 +465,6 @@ export default function Settings() {
{/* ── Appearance ───────────────────────────────────────────────────────── */}
{activeTab === 'appearance' && (
<>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Globe size={18} />
@@ -499,6 +486,16 @@ export default function Settings() {
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
<section className="settings-section">
<div className="settings-section-header">
<Type size={18} />
+1 -1
View File
@@ -224,7 +224,7 @@ export default function Statistics() {
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 500, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{track.name}</span>
{track.nowPlaying && (
<span style={{ fontSize: '0.65rem', fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--accent)', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase', flexShrink: 0 }}>{t('statistics.lfmNowPlaying')}</span>
)}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+1 -1
View File
@@ -11,7 +11,7 @@ interface FontState {
export const useFontStore = create<FontState>()(
persist(
(set) => ({
font: 'inter',
font: 'lexend',
setFont: (font) => set({ font }),
}),
{ name: 'psysonic_font' }
+17
View File
@@ -0,0 +1,17 @@
import { create } from 'zustand';
type SidebarTab = 'queue' | 'lyrics';
interface LyricsState {
activeTab: SidebarTab;
setTab: (tab: SidebarTab) => void;
showLyrics: () => void;
showQueue: () => void;
}
export const useLyricsStore = create<LyricsState>()((set) => ({
activeTab: 'queue',
setTab: (tab) => set({ activeTab: tab }),
showLyrics: () => set({ activeTab: 'lyrics' }),
showQueue: () => set({ activeTab: 'queue' }),
}));
+81 -2
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, SubsonicSong } from '../api/subsonic';
import { buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong } from '../api/subsonic';
import { lastfmScrobble, lastfmUpdateNowPlaying, lastfmLoveTrack, lastfmUnloveTrack, lastfmGetTrackLoved, lastfmGetAllLovedTracks } from '../api/lastfm';
import { useAuthStore } from './authStore';
@@ -23,6 +23,7 @@ export interface Track {
replayGainTrackDb?: number;
replayGainAlbumDb?: number;
replayGainPeak?: number;
starred?: string;
}
export function songToTrack(song: SubsonicSong): Track {
@@ -43,6 +44,7 @@ export function songToTrack(song: SubsonicSong): Track {
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
};
}
@@ -58,6 +60,8 @@ interface PlayerState {
scrobbled: boolean;
lastfmLoved: boolean;
lastfmLovedCache: Record<string, boolean>;
starredOverrides: Record<string, boolean>;
setStarredOverride: (id: string, starred: boolean) => void;
playTrack: (track: Track, queue?: Track[]) => void;
pause: () => void;
@@ -123,6 +127,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// to the Rust backend before it has finished the previous one.
let togglePlayLock = false;
// Timestamp of the last gapless auto-advance (from audio:track_switched).
// Used to suppress ghost-commands from stale IPC arriving after the switch.
let lastGaplessSwitchTime = 0;
// ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -151,9 +159,10 @@ function handleAudioProgress(current_time: number, duration: number) {
const progress = current_time / dur;
usePlayerStore.setState({ currentTime: current_time, progress, buffered: 0 });
// Scrobble at 50% directly via Last.fm
// Scrobble at 50%: Last.fm + Navidrome (updates play_date / recently played)
if (progress >= 0.5 && !store.scrobbled) {
usePlayerStore.setState({ scrobbled: true });
scrobbleSong(track.id, Date.now());
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (scrobblingEnabled && lastfmSessionKey) {
lastfmScrobble(track, Date.now(), lastfmSessionKey);
@@ -199,6 +208,12 @@ function handleAudioProgress(current_time: number, duration: number) {
}
function handleAudioEnded() {
// If a gapless switch happened recently, this ended event is stale — the
// progress task fired it for the OLD source before seeing the chained one.
if (Date.now() - lastGaplessSwitchTime < 600) {
return;
}
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
@@ -211,6 +226,61 @@ function handleAudioEnded() {
}, 150);
}
/**
* Handle gapless auto-advance: the Rust engine has already switched to the
* next source sample-accurately. We just need to update the UI state without
* touching the audio stream (no playTrack() call!).
*/
function handleAudioTrackSwitched(duration: number) {
lastGaplessSwitchTime = Date.now();
isAudioPaused = false;
const store = usePlayerStore.getState();
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
let nextTrack: Track | null = null;
let newIndex = queueIndex;
if (repeatMode === 'one' && store.currentTrack) {
nextTrack = store.currentTrack;
// queueIndex stays the same
} else if (nextIdx < queue.length) {
nextTrack = queue[nextIdx];
newIndex = nextIdx;
} else if (repeatMode === 'all' && queue.length > 0) {
nextTrack = queue[0];
newIndex = 0;
}
if (!nextTrack) return;
usePlayerStore.setState({
currentTrack: nextTrack,
queueIndex: newIndex,
isPlaying: true,
progress: 0,
currentTime: 0,
buffered: 0,
scrobbled: false,
lastfmLoved: false,
});
// Report Now Playing to Navidrome + Last.fm
reportNowPlaying(nextTrack.id);
const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState();
if (lastfmSessionKey) {
if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey);
lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => {
const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`;
usePlayerStore.setState(s => ({
lastfmLoved: loved,
lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved },
}));
});
}
syncQueueToServer(queue, nextTrack, 0);
}
function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false;
@@ -236,6 +306,7 @@ export function initAudioListeners(): () => void {
),
listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
];
// Sync Last.fm loved tracks cache on startup.
@@ -277,6 +348,8 @@ export const usePlayerStore = create<PlayerState>()(
scrobbled: false,
lastfmLoved: false,
lastfmLovedCache: {},
starredOverrides: {},
setStarredOverride: (id, starred) => set(s => ({ starredOverrides: { ...s.starredOverrides, [id]: starred } })),
isQueueVisible: true,
isFullscreenOpen: false,
repeatMode: 'off',
@@ -357,6 +430,12 @@ export const usePlayerStore = create<PlayerState>()(
// ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => {
// Ghost-command guard: if a gapless switch happened within 500 ms,
// this playTrack call is likely a stale IPC echo — suppress it.
if (Date.now() - lastGaplessSwitchTime < 500) {
return;
}
const gen = ++playGeneration;
isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade' | 'imperial-sith' | 'order-of-the-phoenix' | 'heisenberg' | 'ice-and-fire' | 'doh-matic';
interface ThemeState {
theme: Theme;
+231 -83
View File
@@ -435,6 +435,7 @@
background: var(--accent);
}
.album-card-info {
padding: var(--space-3) var(--space-4) var(--space-4);
}
@@ -664,16 +665,17 @@
z-index: 9000;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: flex-start;
align-items: center;
justify-content: center;
padding-top: 8vh;
padding: 40px;
box-sizing: border-box;
cursor: zoom-out;
backdrop-filter: blur(6px);
}
.cover-lightbox-img {
max-width: 90vw;
max-height: 90vh;
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: var(--radius-lg);
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
@@ -933,6 +935,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background var(--transition-fast);
user-select: none;
}
.track-row.track-row-va {
@@ -1212,6 +1215,7 @@
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
user-select: text;
}
.artist-bio a {
@@ -1299,6 +1303,7 @@
line-height: 1.75;
color: var(--text-secondary);
max-width: 100%;
user-select: text;
}
.artist-bio-text a {
@@ -1485,6 +1490,170 @@
overflow: hidden;
}
/* ── Theme Accordion ─────────────────────────────────────────────────────── */
.theme-accordion {
display: flex;
flex-direction: column;
}
.theme-accordion-item {
border-bottom: 1px solid var(--border-subtle);
}
.theme-accordion-item:last-child {
border-bottom: none;
}
.theme-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
width: 100%;
padding: 10px var(--space-4);
text-align: left;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--text-muted);
background: var(--bg-card);
border-left: 3px solid transparent;
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
}
.theme-accordion-header:hover {
background: var(--bg-hover);
color: var(--text-secondary);
}
.theme-accordion-open .theme-accordion-header {
color: var(--accent);
background: var(--bg-hover);
border-left: 3px solid var(--accent);
padding-left: calc(var(--space-5) + 3px);
}
.theme-accordion-chevron {
flex-shrink: 0;
color: var(--text-muted);
transition: transform 0.2s ease;
}
.theme-accordion-open .theme-accordion-chevron {
transform: rotate(180deg);
color: var(--accent);
}
.theme-accordion-content {
padding: 14px var(--space-4) var(--space-4) calc(var(--space-4) + 3px);
background: var(--bg-app);
border-top: 1px solid var(--border-subtle);
border-left: 3px solid var(--accent);
}
.theme-accordion-active-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--accent);
margin-left: 8px;
vertical-align: middle;
opacity: 0.8;
}
/* ── Queue Tab Bar ────────────────────────────────────────────────────────── */
.queue-tab-bar {
display: flex;
border-top: 1px solid var(--border-subtle);
flex-shrink: 0;
}
.queue-tab-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 8px;
background: none;
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
transition: color 0.15s, background 0.15s;
}
.queue-tab-btn:hover { color: var(--text-primary); background: var(--bg-hover); }
.queue-tab-btn.active { color: var(--accent); }
/* ── Lyrics Pane (sidebar) ────────────────────────────────────────────────── */
.lyrics-pane {
flex: 1;
overflow-y: auto;
padding: 16px 16px 8px;
scroll-behavior: smooth;
}
.lyrics-pane-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.lyrics-status {
text-align: center;
color: var(--text-muted);
font-size: 13px;
padding: 40px 0;
}
.lyrics-synced {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 0 16px;
}
.lyrics-line {
font-size: 14px;
font-weight: 400;
color: var(--text-muted);
text-align: center;
padding: 5px 8px;
border-radius: 6px;
line-height: 1.6;
transition: color 0.25s, font-size 0.2s, font-weight 0.2s;
cursor: default;
}
.lyrics-line.active {
color: var(--text-primary);
font-size: 15px;
font-weight: 600;
}
.lyrics-plain {
padding: 4px 0 16px;
}
.lyrics-plain-line {
font-size: 13px;
line-height: 1.85;
color: var(--text-secondary);
text-align: center;
margin: 0;
}
/* ── Help ─────────────────────────────────────────────────────────────────── */
.help-item {
border-bottom: 1px solid var(--border-subtle);
}
@@ -1794,6 +1963,11 @@
}
/* ── Cover breathing ── */
@keyframes ambilight-pulse {
0%, 100% { opacity: 0.7; }
50% { opacity: 1.0; }
}
@keyframes cover-breathe {
0%,
@@ -2327,6 +2501,7 @@
padding: 6px var(--space-3);
border-radius: var(--radius-md);
transition: background 0.15s;
user-select: none;
}
.playlist-row:hover {
@@ -2643,26 +2818,24 @@
/* ── Hero card: 3-column (cover+info | EQ | tag cloud) ── */
.np-hero-card {
display: flex;
align-items: stretch;
gap: 20px;
padding: 24px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 28px;
padding: 32px;
background: rgba(0, 0, 0, 0.30);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-radius: var(--radius-lg);
border: 1px solid rgba(255, 255, 255, 0.08);
flex-shrink: 0;
min-height: 200px;
}
/* Left column: cover + meta, left-aligned — takes equal share with right */
/* Left column: meta info, big text */
.np-hero-left {
flex: 1;
min-width: 0;
display: flex;
align-items: flex-start;
gap: 18px;
align-items: center;
}
.np-hero-cover-wrap {
@@ -2678,58 +2851,44 @@
min-width: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 4px;
gap: 14px;
justify-content: center;
}
/* Center column: fixed width so it stays truly centered between equal outer columns */
.np-eq-wrap {
flex: 0 0 clamp(100px, 20vw, 220px);
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%);
}
.np-eq-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 80px;
width: 100%;
}
.np-eq-bar {
flex: 1;
min-width: 3px;
max-width: 8px;
background: var(--accent);
border-radius: 2px 2px 0 0;
opacity: 0.85;
height: 8%;
transition: background 0.3s;
will-change: height;
}
/* Right column: tag cloud — takes equal share with left so EQ stays centered */
/* Right column: tag cloud */
.np-tag-cloud {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 0;
}
.np-tag-cloud-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
align-content: flex-start;
padding-top: 4px;
gap: 8px 10px;
justify-content: flex-end;
align-items: center;
overflow: visible;
}
.np-tag-cloud-header {
width: 100%;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255, 255, 255, 0.4);
margin-bottom: 10px;
text-align: right;
}
.np-tag {
font-size: 11px;
font-size: 13px;
font-weight: 600;
padding: 4px 10px;
padding: 6px 14px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.10);
color: rgba(255, 255, 255, 0.75);
@@ -2823,6 +2982,7 @@
font-size: 13px;
line-height: 1.65;
color: rgba(255, 255, 255, 0.72);
user-select: text;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
@@ -3028,9 +3188,18 @@
inset: -15%;
background-size: cover;
background-position: center;
filter: blur(40px) brightness(0.55) saturate(1.6);
filter: blur(70px) brightness(0.55) saturate(1.6);
transition: opacity 0.6s ease;
will-change: opacity;
animation: np-ken-burns 40s ease-in-out infinite alternate;
will-change: transform;
}
@keyframes np-ken-burns {
0% { transform: scale(1.00) translate(0%, 0%); }
25% { transform: scale(1.04) translate(-2%, -1.5%); }
50% { transform: scale(1.06) translate(1.5%, 2%); }
75% { transform: scale(1.03) translate(-1%, 1.5%); }
100% { transform: scale(1.05) translate(2%, -1%); }
}
.np-bg-overlay {
@@ -3061,35 +3230,13 @@
justify-content: center;
}
.np-cover-glow {
position: absolute;
width: 200px;
height: 200px;
border-radius: 50%;
object-fit: cover;
filter: blur(36px) saturate(1.5) brightness(0.85);
opacity: 0.7;
transform: translateY(12px) scale(1.05);
pointer-events: none;
z-index: 0;
will-change: transform, opacity;
animation: np-glow-pulse 4s ease-in-out infinite;
}
@keyframes np-glow-pulse {
0%, 100% { opacity: 0.6; transform: translateY(14px) scale(1.04); }
50% { opacity: 0.85; transform: translateY(9px) scale(1.10); }
}
.np-cover {
position: relative;
z-index: 1;
width: 196px;
height: 196px;
border-radius: 14px;
width: 280px;
height: 280px;
border-radius: 16px;
object-fit: cover;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6);
animation: cover-breathe 6s ease-in-out infinite;
}
.np-cover-fallback {
@@ -3112,14 +3259,14 @@
.np-title {
font-family: var(--font-display);
font-size: 26px;
font-size: 36px;
font-weight: 700;
line-height: 1.2;
line-height: 1.15;
color: white;
}
.np-artist-album {
font-size: 15px;
font-size: 19px;
color: rgba(255, 255, 255, 0.75);
display: flex;
flex-wrap: wrap;
@@ -3350,6 +3497,7 @@
border-radius: var(--radius-md);
cursor: pointer;
transition: background 0.12s;
user-select: none;
}
.np-queue-item:hover,
+1 -1
View File
@@ -576,7 +576,7 @@
box-sizing: border-box;
padding: 0 var(--space-4);
background: var(--bg-app);
border-bottom: 1px solid var(--bg-surface);
border-bottom: 1px solid var(--border-subtle);
display: flex;
justify-content: space-between;
align-items: center;
+1813 -145
View File
File diff suppressed because it is too large Load Diff