feat: v1.12.0 — Lyrics, 15 new themes, Last.fm stable, Crossfade stable

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-22 13:10:06 +01:00
parent 867c5fbd3e
commit d927ef2082
24 changed files with 2168 additions and 411 deletions
+44
View File
@@ -5,6 +5,50 @@ 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/), 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). 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 ## [1.11.0] - 2026-03-22
### Added ### Added
+44 -27
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/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/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-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/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/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. | | `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`. Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes ### 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). `--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere).
@@ -125,30 +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` | | `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` |
| `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` | | `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` |
| `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` | | `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` |
| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` | | `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` |
| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` | | `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` |
| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` | | `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` |
| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` | | `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` |
| `mocha` | Catppuccin | dark | Mauve | | `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` |
| `macchiato` | Catppuccin | medium-dark | Mauve | | `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` |
| `frappe` | Catppuccin | medium | Mauve | | `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` |
| `latte` | Catppuccin | light | Mauve | | `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` |
| `nord` | Nord | Polar Night dark | Frost `#88c0d0` | | `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` |
| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` | | `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` |
| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` | | `grand-theft-audio` | Games | GTA night city | Green `#57b05a` |
| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` | | `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` |
| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` | | `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` |
| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` | | `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` |
| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` | | `blade` | Movies | deep black, blood-red | Red `#b30000` |
| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` | | `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` |
| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` | | `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` |
| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` | | `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` |
| `tokyo-night` | Tokyo Night | dark blue | Blue `#7aa2f7` | | `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` |
| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` | | `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` |
| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` | | `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` |
| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` | | `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` |
| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` | | `doh-matic` | Series | The Simpsons | Blue `#1F75FE` |
| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` | | `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). **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).
@@ -227,4 +244,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **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`). - **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`).
- **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. - **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. - **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.11.0 - **Version**: 1.12.0
+6 -8
View File
@@ -22,11 +22,12 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features ## ✨ 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. -**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. - 🌍 **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. - 📻 **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. - 💾 **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 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. - 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing.
@@ -47,22 +48,19 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Replay Gain (track + album mode) - [x] Replay Gain (track + album mode)
- [x] Gapless playback - [x] Gapless playback
- [x] Waveform seekbar - [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] Similar Artists via Last.fm, filtered to library
- [x] Statistics — Last.fm top charts & recent scrobbles - [x] Statistics — Last.fm top charts & recent scrobbles
- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll)
- [x] Multi-server support - [x] Multi-server support
- [x] IndexedDB image caching - [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix - [x] Random Mix with keyword filter & Super Genre mix
- [x] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, 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] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS) - [x] AUR package (Arch / CachyOS)
- [x] Configurable keybindings - [x] Configurable keybindings
- [x] Font picker (10 UI fonts) - [x] Font picker (10 UI fonts)
### 🚧 In Progress
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Crossfade** — stability improvements
### 📋 Planned ### 📋 Planned
- [ ] FLAC seeking fix - [ ] FLAC seeking fix
- [ ] General UI polish & visual refinement - [ ] General UI polish & visual refinement
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.11.0", "version": "1.12.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: Psychotoxic <psychotoxic@gmx.de> # Maintainer: Psychotoxic <psychotoxic@gmx.de>
pkgname=psysonic pkgname=psysonic
pkgver=1.11.0 pkgver=1.12.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.11.0" version = "1.12.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.11.0" version = "1.12.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+349 -51
View File
@@ -5,6 +5,7 @@ use std::time::{Duration, Instant};
use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType}; use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType};
use rodio::{Decoder, Sink, Source}; use rodio::{Decoder, Sink, Source};
use rodio::source::UniformSourceIterator;
use serde::Serialize; use serde::Serialize;
use tauri::{AppHandle, Emitter, State}; 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() (t * std::f32::consts::FRAC_PI_2).sin()
}; };
self.sample_count += 1; 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 ─────── // ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
// //
// This is the key mechanism for gapless: the progress task polls `done` to know // 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) ───────────────────────────────────────── // ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
// //
// MP3/AAC encoders prepend an "encoder delay" (typically 5762112 silent // 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 } 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`: /// `fade_in_dur`:
/// • `Duration::ZERO` — unity gain; used for gapless chain (no click) /// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-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 /// • `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( fn build_source(
data: Vec<u8>, data: Vec<u8>,
duration_hint: f64, duration_hint: f64,
@@ -339,12 +474,15 @@ fn build_source(
eq_enabled: Arc<AtomicBool>, eq_enabled: Arc<AtomicBool>,
done_flag: Arc<AtomicBool>, done_flag: Arc<AtomicBool>,
fade_in_dur: Duration, 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 gapless = parse_gapless_info(&data);
let cursor = Cursor::new(data); let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?; let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
let sample_rate = decoder.sample_rate(); let sample_rate = decoder.sample_rate();
let channels = decoder.channels();
// Determine effective duration. // Determine effective duration.
// Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3). // Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
@@ -356,7 +494,8 @@ fn build_source(
.unwrap_or(duration_hint) .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 dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
let delay_dur = Duration::from_secs_f64( let delay_dur = Duration::from_secs_f64(
gapless.delay_samples as f64 / sample_rate as 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 { if let Some(total) = gapless.total_valid_samples {
let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64); 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 { } 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 { } 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 eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur); 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 ───────────────────────────────────────────────────────────── // ─── Engine state ─────────────────────────────────────────────────────────────
@@ -397,6 +564,9 @@ pub(crate) struct ChainedInfo {
base_volume: f32, base_volume: f32,
/// Set by NotifyingSource when this chained track's source is exhausted. /// Set by NotifyingSource when this chained track's source is exhausted.
source_done: Arc<AtomicBool>, source_done: Arc<AtomicBool>,
/// Atomic sample counter for this chained source (swapped into
/// samples_played on transition).
sample_counter: Arc<AtomicU64>,
} }
pub struct AudioEngine { pub struct AudioEngine {
@@ -417,6 +587,16 @@ pub struct AudioEngine {
/// Info about the next-up chained track (gapless mode). /// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires. /// The progress task reads this when `current_source_done` fires.
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>, 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 { pub struct AudioCurrent {
@@ -427,6 +607,10 @@ pub struct AudioCurrent {
pub paused_at: Option<f64>, pub paused_at: Option<f64>,
pub replay_gain_linear: f32, pub replay_gain_linear: f32,
pub base_volume: 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 { 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() let thread = std::thread::Builder::new()
.name("psysonic-audio-stream".into()) .name("psysonic-audio-stream".into())
.spawn(move || match rodio::OutputStream::try_default() { .spawn(move || match rodio::OutputStream::try_default() {
@@ -482,6 +676,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
paused_at: None, paused_at: None,
replay_gain_linear: 1.0, replay_gain_linear: 1.0,
base_volume: 0.8, base_volume: 0.8,
fadeout_trigger: None,
fadeout_samples: None,
})), })),
generation: Arc::new(AtomicU64::new(0)), generation: Arc::new(AtomicU64::new(0)),
http_client: reqwest::Client::builder() 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)), fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)), gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)), 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) (engine, thread)
@@ -574,6 +774,24 @@ pub async fn audio_play(
) -> Result<(), String> { ) -> Result<(), String> {
let gapless = state.gapless_enabled.load(Ordering::Relaxed); 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 ───────────────────────────────────────────────── // ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in // 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 // 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) 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 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, data,
duration_hint, duration_hint,
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
done_flag.clone(), done_flag.clone(),
fade_in_dur, fade_in_dur,
state.samples_played.clone(),
target_rate,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?; ).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 { if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); return Ok(());
@@ -681,11 +912,12 @@ pub async fn audio_play(
sink.append(source); sink.append(source);
// Atomically swap sinks. // Atomically swap sinks — extract old sink + its fade-out trigger.
let (old_sink, old_vol) = { let (old_sink, old_fadeout_trigger, old_fadeout_samples) = {
let mut cur = state.current.lock().unwrap(); 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 = cur.sink.take();
let old_fo_trigger = cur.fadeout_trigger.take();
let old_fo_samples = cur.fadeout_samples.take();
cur.sink = Some(sink); cur.sink = Some(sink);
cur.duration_secs = duration_secs; cur.duration_secs = duration_secs;
cur.seek_offset = 0.0; cur.seek_offset = 0.0;
@@ -693,39 +925,33 @@ pub async fn audio_play(
cur.paused_at = None; cur.paused_at = None;
cur.replay_gain_linear = gain_linear; cur.replay_gain_linear = gain_linear;
cur.base_volume = volume.clamp(0.0, 1.0); 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 crossfade_enabled {
if let Some(old) = old_sink { 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); *state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone(); let fo_arc = state.fading_out_sink.clone();
let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5);
tokio::spawn(async move { tokio::spawn(async move {
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade. tokio::time::sleep(cleanup_dur).await;
// 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;
}
if let Some(s) = fo_arc.lock().unwrap().take() { if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop(); s.stop();
} }
@@ -747,6 +973,10 @@ pub async fn audio_play(
state.crossfade_secs.clone(), state.crossfade_secs.clone(),
done_flag, done_flag,
app, app,
state.samples_played.clone(),
state.current_sample_rate.clone(),
state.current_channels.clone(),
state.gapless_switch_at.clone(),
); );
Ok(()) 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 (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let done_next = Arc::new(AtomicBool::new(false)); 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, data,
duration_hint, duration_hint,
state.eq_gains.clone(), state.eq_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
done_next.clone(), done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
chain_counter.clone(),
target_rate,
).map_err(|e| e.to_string())?; ).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. // Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen { if state.generation.load(Ordering::SeqCst) != snapshot_gen {
@@ -847,6 +1085,7 @@ pub async fn audio_chain_preload(
replay_gain_linear: gain_linear, replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0), base_volume: volume.clamp(0.0, 1.0),
source_done: done_next, source_done: done_next,
sample_counter: chain_counter,
}); });
Ok(()) 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 /// 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 /// chained source's flag and transitions state — all without creating a new
/// task or changing the generation counter. /// 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( fn spawn_progress_task(
gen: u64, gen: u64,
gen_counter: Arc<AtomicU64>, gen_counter: Arc<AtomicU64>,
@@ -868,6 +1113,10 @@ fn spawn_progress_task(
crossfade_secs_arc: Arc<AtomicU32>, crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>, initial_done: Arc<AtomicBool>,
app: AppHandle, app: AppHandle,
samples_played: Arc<AtomicU64>,
sample_rate_arc: Arc<AtomicU32>,
channels_arc: Arc<AtomicU32>,
gapless_switch_at: Arc<AtomicU64>,
) { ) {
tokio::spawn(async move { tokio::spawn(async move {
let mut near_end_ticks: u32 = 0; let mut near_end_ticks: u32 = 0;
@@ -875,7 +1124,8 @@ fn spawn_progress_task(
let mut current_done = initial_done; let mut current_done = initial_done;
loop { 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 { if gen_counter.load(Ordering::SeqCst) != gen {
break; break;
@@ -884,24 +1134,40 @@ fn spawn_progress_task(
// ── Gapless transition detection ───────────────────────────────── // ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track // If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit // 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) { if current_done.load(Ordering::SeqCst) {
let chained = chained_arc.lock().unwrap().take(); let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained { if let Some(info) = chained {
// Swap to the chained source's done flag. // Swap to the chained source's done flag.
current_done = info.source_done; 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(); let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear; cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume; cur.base_volume = info.base_volume;
// Reset play_started to now — the old track physically cur.duration_secs = info.duration_secs;
// ended, the new one is now actively producing samples.
cur.seek_offset = 0.0; cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now()); 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; near_end_ticks = 0;
continue; continue;
} }
@@ -910,9 +1176,26 @@ fn spawn_progress_task(
// the near-end logic below. // 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(); 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(); 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 { if dur > end_threshold && pos >= dur - end_threshold {
near_end_ticks += 1; 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); gen_counter.fetch_add(1, Ordering::SeqCst);
app.emit("audio:ended", ()).ok(); app.emit("audio:ended", ()).ok();
break; break;
@@ -982,6 +1266,20 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command] #[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> { 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. // Seeking far back invalidates any pending gapless chain.
let cur_pos = { let cur_pos = {
let cur = state.current.lock().unwrap(); let cur = state.current.lock().unwrap();
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.11.0", "version": "1.12.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -23,7 +23,8 @@
"decorations": true, "decorations": true,
"transparent": false, "transparent": false,
"visible": true, "visible": true,
"dragDropEnabled": false "dragDropEnabled": false,
"devtools": false
} }
], ],
"security": { "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);
}
+1 -1
View File
@@ -191,7 +191,7 @@ export default function AlbumHeader({
id="album-star-btn" id="album-star-btn"
onClick={onToggleStar} onClick={onToggleStar}
data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} 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'} /> <Star size={16} fill={isStarred ? 'currentColor' : 'none'} />
{t('albumDetail.favorite')} {t('albumDetail.favorite')}
+1 -1
View File
@@ -156,7 +156,7 @@ export default function AlbumTrackList({
className="btn btn-ghost track-star-btn" className="btn btn-ghost track-star-btn"
onClick={e => onToggleSongStar(song, e)} onClick={e => onToggleSongStar(song, e)}
data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} 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'} /> <Star size={14} fill={starredSongs.has(song.id) ? 'currentColor' : 'none'} />
</button> </button>
+15 -1
View File
@@ -1,9 +1,10 @@
import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import React, { useCallback, useEffect, useState, useRef, memo } from 'react';
import { import {
Play, Pause, SkipBack, SkipForward, Play, Pause, SkipBack, SkipForward,
ChevronDown, Repeat, Repeat1, Square, Music ChevronDown, Repeat, Repeat1, Square, Music, MicVocal
} from 'lucide-react'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic';
import CachedImage, { useCachedUrl } from './CachedImage'; import CachedImage, { useCachedUrl } from './CachedImage';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
const stop = usePlayerStore(s => s.stop); const stop = usePlayerStore(s => s.stop);
const toggleRepeat = usePlayerStore(s => s.toggleRepeat); 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 duration = currentTrack?.duration ?? 0;
const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : '';
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(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} />} {repeatMode === 'one' ? <Repeat1 size={14} /> : <Repeat size={14} />}
</button> </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>
</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>
);
}
+16 -2
View File
@@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, 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'; } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
@@ -12,6 +12,7 @@ import WaveformSeek from './WaveformSeek';
import Equalizer from './Equalizer'; import Equalizer from './Equalizer';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -24,11 +25,14 @@ export default function PlayerBar() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [eqOpen, setEqOpen] = useState(false); const [eqOpen, setEqOpen] = useState(false);
const showLyrics = useLyricsStore(s => s.showLyrics);
const activeTab = useLyricsStore(s => s.activeTab);
const { const {
currentTrack, isPlaying, currentTime, volume, currentTrack, isPlaying, currentTime, volume,
togglePlay, next, previous, setVolume, togglePlay, next, previous, setVolume,
stop, toggleRepeat, repeatMode, toggleFullscreen, stop, toggleRepeat, repeatMode, toggleFullscreen,
lastfmLoved, toggleLastfmLove, lastfmLoved, toggleLastfmLove,
isQueueVisible, toggleQueue,
} = usePlayerStore(); } = usePlayerStore();
const { lastfmSessionKey } = useAuthStore(); const { lastfmSessionKey } = useAuthStore();
@@ -115,7 +119,7 @@ export default function PlayerBar() {
aria-label={isPlaying ? t('player.pause') : t('player.play')} aria-label={isPlaying ? t('player.pause') : t('player.play')}
data-tooltip={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>
<button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}> <button className="player-btn" onClick={next} aria-label={t('player.next')} data-tooltip={t('player.next')}>
<SkipForward size={19} /> <SkipForward size={19} />
@@ -140,6 +144,16 @@ export default function PlayerBar() {
<span className="player-time">{formatTime(duration)}</span> <span className="player-time">{formatTime(duration)}</span>
</div> </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 */} {/* EQ Button */}
<button <button
className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`} className={`player-btn player-btn-sm player-eq-btn ${eqOpen ? 'active' : ''}`}
+30 -2
View File
@@ -1,11 +1,13 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore'; 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 { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import LyricsPane from './LyricsPane';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -139,6 +141,9 @@ export default function QueuePanel() {
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const activeTab = useLyricsStore(s => s.activeTab);
const setTab = useLyricsStore(s => s.setTab);
const [showRemainingTime, setShowRemainingTime] = useState(false); const [showRemainingTime, setShowRemainingTime] = useState(false);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null); const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
@@ -348,7 +353,8 @@ export default function QueuePanel() {
</div> </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')}> <button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} /> <Shuffle size={13} />
</button> </button>
@@ -472,6 +478,28 @@ export default function QueuePanel() {
}) })
)} )}
</div> </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 && ( {saveModalOpen && (
<SavePlaylistModal <SavePlaylistModal
+169 -155
View File
@@ -1,4 +1,5 @@
import { Check } from 'lucide-react'; import { useState } from 'react';
import { Check, ChevronDown } from 'lucide-react';
interface ThemeDef { interface ThemeDef {
id: string; id: string;
@@ -10,81 +11,85 @@ interface ThemeDef {
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [ const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ {
group: 'Psysonic Themes', group: 'Games',
themes: [ themes: [
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' }, { id: 'ascalon', label: 'Ascalon', bg: '#1c1a17', card: '#0f0d0b', accent: '#d4af37' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#7a5218' }, { id: 'azerothian-gold', label: 'Azerothian Gold', bg: '#1a1a1a', card: '#0a0a0a', accent: '#c19e67' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' }, { id: 'grand-theft-audio', label: 'Grand Theft Audio', bg: '#141414', card: '#0a0a0a', accent: '#57b05a' },
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' }, { id: 'lambda-17', label: 'Lambda 17', bg: '#14171a', card: '#0a0b0c', accent: '#ff9d00' },
{ id: 'neon-drift', label: 'Neon Drift', bg: '#12132c', card: '#080916', accent: '#00f2ff' }, { 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: '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: 'spotless', label: 'Spotless', bg: '#121212', card: '#181818', accent: '#1ED760' },
{ id: 'dzr0', label: 'DZR', bg: '#FFFFFF', card: '#F5F5F7', accent: '#A238FF' },
{ id: 'cupertino-beats', label: 'Cupertino Beats', bg: '#1c1c1e', card: '#2c2c2e', accent: '#fa243c' },
],
},
{
group: 'Operating Systems',
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' },
], ],
}, },
{ {
group: 'Movies', group: 'Movies',
themes: [ themes: [
{ id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' }, { id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' },
{ id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' }, { id: 'imperial-sith', label: 'Imperial Sith', bg: '#0f0f11', card: '#050505', accent: '#e60000' },
{ id: 'pandora', label: 'Pandora', bg: '#0c1b22', card: '#142b35', accent: '#00f2ff' }, { id: 'middle-earth', label: 'Middle Earth', bg: '#f4e4bc', card: '#2a1d15', accent: '#d4af37' },
{ id: 'stark-hud', label: 'Stark HUD', bg: '#0b0f15', card: '#05070a', accent: '#00f2ff' }, { id: 'morpheus', label: 'Morpheus', bg: '#0a0a0a', card: '#000000', accent: '#00ff41' },
{ id: 'blade', label: 'Blade', bg: '#121212', card: '#050505', accent: '#b30000' }, { 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: '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: '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: 'Series',
themes: [
{ 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' },
], ],
}, },
]; ];
@@ -95,93 +100,102 @@ interface Props {
} }
export default function ThemePicker({ value, onChange }: 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 ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> <div className="theme-accordion">
{THEME_GROUPS.map(({ group, themes }) => ( {THEME_GROUPS.map(({ group, themes }) => {
<div key={group}> const isOpen = openGroup === group;
<div style={{ const hasActive = themes.some(t => t.id === value);
fontSize: '11px', return (
fontWeight: 600, <div key={group} className={`theme-accordion-item${isOpen ? ' theme-accordion-open' : ''}`}>
textTransform: 'uppercase', <button className="theme-accordion-header" onClick={() => toggle(group)}>
letterSpacing: '0.08em', <span>
color: 'var(--text-muted)', {group}
marginBottom: '10px', {hasActive && !isOpen && (
}}> <span className="theme-accordion-active-dot" />
{group} )}
</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>
<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> </div>
); );
} }
+16 -4
View File
@@ -525,7 +525,10 @@ const enTranslation = {
repeatOne: 'One', repeatOne: 'One',
progress: 'Song Progress', progress: 'Song Progress',
volume: 'Volume', volume: 'Volume',
toggleQueue: 'Toggle Queue' toggleQueue: 'Toggle Queue',
lyrics: 'Lyrics',
lyricsLoading: 'Loading lyrics…',
lyricsNotFound: 'No lyrics found for this track',
} }
}; };
@@ -1053,7 +1056,10 @@ const deTranslation = {
repeatOne: 'Einen', repeatOne: 'Einen',
progress: 'Songfortschritt', progress: 'Songfortschritt',
volume: 'Lautstärke', volume: 'Lautstärke',
toggleQueue: 'Warteschlange umschalten' toggleQueue: 'Warteschlange umschalten',
lyrics: 'Lyrics',
lyricsLoading: 'Lyrics werden geladen…',
lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden',
} }
}; };
@@ -1581,7 +1587,10 @@ const frTranslation = {
repeatOne: 'Un', repeatOne: 'Un',
progress: 'Progression', progress: 'Progression',
volume: 'Volume', 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',
} }
}; };
@@ -2109,7 +2118,10 @@ const nlTranslation = {
repeatOne: 'Één', repeatOne: 'Één',
progress: 'Nummervoortgang', progress: 'Nummervoortgang',
volume: 'Volume', volume: 'Volume',
toggleQueue: 'Wachtrij in-/uitschakelen' toggleQueue: 'Wachtrij in-/uitschakelen',
lyrics: 'Songtekst',
lyricsLoading: 'Songtekst laden…',
lyricsNotFound: 'Geen songtekst gevonden voor dit nummer',
} }
}; };
+19 -6
View File
@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; 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 { usePlayerStore } from '../store/playerStore';
import { useLyricsStore } from '../store/lyricsStore';
import { import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
getAlbum, getArtistInfo, getAlbum, getArtistInfo,
@@ -123,12 +124,12 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
const getTagStyle = (name: string, idx: number): React.CSSProperties => { const getTagStyle = (name: string, idx: number): React.CSSProperties => {
const h = strHash(name); const h = strHash(name);
const sizePool = [12, 13, 14, 15, 16, 17, 18, 20, 22]; const sizePool = [10, 11, 12, 13, 14, 15, 16];
const size = sizePool[(h + idx * 7) % sizePool.length]; const size = sizePool[(h + idx * 7) % sizePool.length];
const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400; const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400;
const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px'; const pad = size >= 15 ? '5px 10px' : '4px 8px';
const opacity = 0.6 + ((h % 5) * 0.08); const opacity = 0.6 + ((h % 5) * 0.08);
const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2]; 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]; const ty = verticals[(h + idx * 4) % verticals.length];
return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` }; return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` };
}; };
@@ -137,7 +138,7 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
<div className="np-tag-cloud"> <div className="np-tag-cloud">
<div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div> <div className="np-tag-cloud-header">{t('artistDetail.similarArtists')}</div>
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => ( {([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: '26px' } : undefined}> <div key={rowIdx} className="np-tag-cloud-tags" style={rowIdx === 0 ? { marginBottom: '14px' } : undefined}>
{row.map((a, i) => ( {row.map((a, i) => (
<span <span
key={a.id} key={a.id}
@@ -239,6 +240,10 @@ export default function NowPlaying() {
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const isPlaying = usePlayerStore(s => s.isPlaying); const isPlaying = usePlayerStore(s => s.isPlaying);
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 stableNavigate = useCallback((path: string) => navigate(path), [navigate]); const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
@@ -356,6 +361,14 @@ export default function NowPlaying() {
> >
<Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} /> <Star size={17} fill={starred ? 'var(--ctp-yellow)' : 'none'} color={starred ? 'var(--ctp-yellow)' : 'white'} />
</button> </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> </div>
</div> </div>
+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' }),
}));
+72
View File
@@ -127,6 +127,10 @@ let seekDebounce: ReturnType<typeof setTimeout> | null = null;
// to the Rust backend before it has finished the previous one. // to the Rust backend before it has finished the previous one.
let togglePlayLock = false; 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 ───────────────────────────────────────────────────────── // ─── Server queue sync ─────────────────────────────────────────────────────────
let syncTimeout: ReturnType<typeof setTimeout> | null = null; let syncTimeout: ReturnType<typeof setTimeout> | null = null;
function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) { function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) {
@@ -204,6 +208,12 @@ function handleAudioProgress(current_time: number, duration: number) {
} }
function handleAudioEnded() { 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(); const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
isAudioPaused = false; isAudioPaused = false;
usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 });
@@ -216,6 +226,61 @@ function handleAudioEnded() {
}, 150); }, 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) { function handleAudioError(message: string) {
console.error('[psysonic] Audio error from backend:', message); console.error('[psysonic] Audio error from backend:', message);
isAudioPaused = false; isAudioPaused = false;
@@ -241,6 +306,7 @@ export function initAudioListeners(): () => void {
), ),
listen<void>('audio:ended', () => handleAudioEnded()), listen<void>('audio:ended', () => handleAudioEnded()),
listen<string>('audio:error', ({ payload }) => handleAudioError(payload)), listen<string>('audio:error', ({ payload }) => handleAudioError(payload)),
listen<number>('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)),
]; ];
// Sync Last.fm loved tracks cache on startup. // Sync Last.fm loved tracks cache on startup.
@@ -364,6 +430,12 @@ export const usePlayerStore = create<PlayerState>()(
// ── playTrack ──────────────────────────────────────────────────────────── // ── playTrack ────────────────────────────────────────────────────────────
playTrack: (track, queue) => { 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; const gen = ++playGeneration;
isAudioPaused = false; isAudioPaused = false;
if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; }
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; 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' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade'; 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 { interface ThemeState {
theme: Theme; theme: Theme;
+170
View File
@@ -935,6 +935,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background var(--transition-fast); transition: background var(--transition-fast);
user-select: none;
} }
.track-row.track-row-va { .track-row.track-row-va {
@@ -1214,6 +1215,7 @@
font-size: 14px; font-size: 14px;
line-height: 1.7; line-height: 1.7;
color: var(--text-secondary); color: var(--text-secondary);
user-select: text;
} }
.artist-bio a { .artist-bio a {
@@ -1301,6 +1303,7 @@
line-height: 1.75; line-height: 1.75;
color: var(--text-secondary); color: var(--text-secondary);
max-width: 100%; max-width: 100%;
user-select: text;
} }
.artist-bio-text a { .artist-bio-text a {
@@ -1487,6 +1490,170 @@
overflow: hidden; 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 { .help-item {
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
} }
@@ -2334,6 +2501,7 @@
padding: 6px var(--space-3); padding: 6px var(--space-3);
border-radius: var(--radius-md); border-radius: var(--radius-md);
transition: background 0.15s; transition: background 0.15s;
user-select: none;
} }
.playlist-row:hover { .playlist-row:hover {
@@ -2814,6 +2982,7 @@
font-size: 13px; font-size: 13px;
line-height: 1.65; line-height: 1.65;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
user-select: text;
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 4; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
@@ -3328,6 +3497,7 @@
border-radius: var(--radius-md); border-radius: var(--radius-md);
cursor: pointer; cursor: pointer;
transition: background 0.12s; transition: background 0.12s;
user-select: none;
} }
.np-queue-item:hover, .np-queue-item:hover,
+1035 -146
View File
File diff suppressed because it is too large Load Diff