diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5da271d4..2b3c6724 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [1.8.0] - 2026-03-21
+
+### Added
+
+#### Three New Themes
+- **Poison**: Dark charcoal background (`#1a1a1a`) with phosphor green (`#1bd655`) accent — high-contrast, industrial aesthetic. LCD glow text-shadow on the now-playing track name.
+- **Nucleo**: Warm brass/cream light theme inspired by vintage hi-fi equipment. Warm white cards, gold/amber accents, brushed-metal bevel buttons, and a warm LCD glow on the player track name. `color-scheme: light`.
+- **Classic Winamp**: Cool gray-blue dark theme (`#2b2b3a`) channelling the classic Winamp 2.x skin. Yellow primary accent (`#d4cc46`), orange volume slider override (`--volume-accent: #de9b35`), Courier New monospace font with bright-green LCD glow for the track name.
+
+#### Psychowave Theme — Major Overhaul
+- Psychowave recoloured from loud neon pink/purple to a refined deep violet palette: background `#161428`, accent `#a06ae0`. All neon colours replaced with muted, tasteful variants. No longer marked as WIP.
+
+#### ThemePicker Redesign
+- Themes reorganised into semantic groups: **Catppuccin**, **Nord**, **Retro** (formerly Gruvbox), **Tokyo Night**, and a new **Psysonic Themes** section (Classic Winamp, Poison, Nucleo, Psychowave). The separate *Experimental* group is removed.
+- "Gruvbox" renamed to **Retro**.
+
+#### Image Lightbox
+- Clicking the **album cover** on an Album Detail page or the **artist avatar** on an Artist Detail page opens a full-screen lightbox showing the high-resolution image (up to 2000 px). Click outside or press Escape to close.
+- Both use a shared `CoverLightbox` component — consistent behaviour across the app.
+
+#### Queue Toolbar — Complete Redesign
+- The queue panel now has a **centred icon toolbar** with round buttons (border-radius 50%, solid accent fill when active):
+ - **Shuffle** — Fisher-Yates shuffle, keeps current track at position 0
+ - **Save** — save queue as playlist
+ - **Load** — load a playlist into the queue
+ - **Clear** — remove all tracks from the queue
+ - **Gapless** (∞ icon) — toggle gapless playback on/off
+ - **Crossfade** (≋ icon) — toggle crossfade on/off; when inactive, clicking enables crossfade *and* opens a popover slider
+- **Crossfade popover**: a small overlay below the Crossfade button with a range slider (1–10 s) to configure the fade duration. Clicking the active Crossfade button disables crossfade and closes the popover. Closes on outside click.
+- **Queue header**: title enlarged to 16 px/700, track count and total duration shown inline next to the title in accent colour. Close (×) button removed.
+- **Tech info overlay**: codec and bitrate displayed as a frosted glass badge (`backdrop-filter: blur(4px)`) overlaid on the bottom edge of the cover art image.
+
+#### French & Dutch Translations
+- Full UI translation added for **French** (`fr`) and **Dutch** (`nl`) — all namespaces covered.
+- Language selector in Settings now lists all four languages sorted alphabetically (Dutch, English, French, German).
+
+#### Help Page — Layout & Content Update
+- **2-column grid layout** for the accordion — makes better use of horizontal space on widescreen displays.
+- New Q&A entry: **Crossfade & Gapless** (Playback section) — explains what each feature does, how to enable them, and their experimental status.
+- Updated entries: Themes (reflects all 21 themes), Languages (4 languages), Scrobbling (direct Last.fm), System browser links, Linux distribution (no AppImage).
+
+#### Settings — Experimental Labels
+- Crossfade and Gapless toggles in Settings → Playback now show an **"Experimental"** badge next to their label.
+
+### Fixed
+
+- **Now Playing dropdown — refresh button**: The refresh icon spin was applied to the entire button, blocking clicks during the animation. Spin state is now separate from the background poll loading state — the button is always clickable, and the icon spins for a minimum of 600 ms for clear visual feedback.
+- **Crossfade popover positioning**: Popover was overflowing the right edge of the viewport. Now right-aligned relative to the Crossfade button and positioned below it.
+
+---
+
## [1.7.2] - 2026-03-20
### Fixed
diff --git a/CLAUDE.md b/CLAUDE.md
index 322c12da..9138c4a4 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,7 +34,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
- **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section.
- **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt)
- **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time).
-- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English + German)
+- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch)
### Key files
@@ -50,14 +50,15 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build.
| `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
-| `src/store/themeStore.ts` | Theme selection (9 themes), applied as `data-theme` on `` |
+| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `` |
| `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command |
| `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `` mount |
-| `src/i18n.ts` | All translations (en + de) inline. Language persisted in `localStorage('psysonic_language')`. |
+| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. |
| `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. |
| `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. |
| `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. |
-| `src/components/QueuePanel.tsx` | Queue sidebar. Shows song count + total duration below title. Items get `.context-active` class while their context menu is open. |
+| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 1–10 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. |
+| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. |
| `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. |
### Multi-server support
@@ -106,19 +107,33 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL
Add a function to `src/api/subsonic.ts` using the `api()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes
-9 themes are available, selectable in Settings via `CustomSelect`. `themeStore` persists the choice and sets `data-theme` on ``. 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.
+21 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on ``. 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.
-| Theme | Style | Accent |
-|---|---|---|
-| `mocha` | Catppuccin dark | Mauve |
-| `macchiato` | Catppuccin medium-dark | Mauve |
-| `frappe` | Catppuccin medium | Mauve |
-| `latte` | Catppuccin light | Mauve |
-| `nord` | Nord Polar Night dark | Frost `#88c0d0` |
-| `nord-snowstorm` | Nord Snow Storm light | Deep-Blue `#5e81ac` |
-| `nord-frost` | Nord deep ocean blue | Frost `#88c0d0` |
-| `nord-aurora` | Nord Polar Night + aurora | Purple `#b48ead` |
-| `psychowave` | Synthwave/retrowave deep purple | Purple `#c084fc` — WIP |
+`--volume-accent` overrides the volume slider colour independently of `--accent` (used by Classic Winamp for orange volume, yellow accent elsewhere).
+
+| Theme | Group | Style | Accent |
+|---|---|---|---|
+| `mocha` | Catppuccin | dark | Mauve |
+| `macchiato` | Catppuccin | medium-dark | Mauve |
+| `frappe` | Catppuccin | medium | Mauve |
+| `latte` | Catppuccin | light | Mauve |
+| `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
+| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
+| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` |
+| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` |
+| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` |
+| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` |
+| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` |
+| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` |
+| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` |
+| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` |
+| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` |
+| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` |
+| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` |
+| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` |
+| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` |
+| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` |
+| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` |
**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).
@@ -132,7 +147,7 @@ Artist images are intentionally **not loaded** on the Artists overview page (gri
`src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available.
### i18n
-All German strings live exclusively in `src/i18n.ts` — never hardcode German in `.tsx` files. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
+All non-English strings live exclusively in `src/i18n.ts` — never hardcode translated text in `.tsx` files. Four languages: `en`, `de`, `fr`, `nl`. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`.
**German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings.
@@ -181,7 +196,8 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover.
- **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`.
-- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned. Default panel width 340 px. Header shows song count + total duration below the queue title.
+- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour.
+- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow.
- **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible.
- **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false.
- **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue.
@@ -192,4 +208,6 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele
- **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal.
- **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget.
- **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden.
-- **Version**: 1.7.1
+- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button.
+- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`).
+- **Version**: 1.8.0
diff --git a/README.md b/README.md
index f74aab41..afd19bee 100644
--- a/README.md
+++ b/README.md
@@ -22,9 +22,9 @@ Designed specifically for users hosting their own music via Navidrome or other S
## ✨ Features
-- 🎨 **Gorgeous UI**: 9 deeply integrated themes (Catppuccin series, Nord series, Psychowave) with smooth glassmorphism effects and micro-animations.
+- 🎨 **Gorgeous UI**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — with smooth glassmorphism effects and micro-animations.
- ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps.
-- 🌍 **Internationalization (i18n)**: Fully translated into English and German.
+- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch.
- 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required.
- 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images.
@@ -53,19 +53,17 @@ Designed specifically for users hosting their own music via Navidrome or other S
- [x] Multi-server support
- [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix
-- [x] 9 themes: Catppuccin series, Nord series, Psychowave
-- [x] Internationalization (English + German)
+- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
+- [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS)
### 🚧 In Progress
-- [ ] **Psychowave theme** — visual refinement still ongoing
- [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements
### 📋 Planned
- [ ] FLAC seeking fix
-- [ ] More themes
- [ ] General UI polish & visual refinement
- [ ] More languages
diff --git a/package.json b/package.json
index ea8cdfbf..0f6e6660 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "psysonic",
- "version": "1.7.2",
+ "version": "1.8.0",
"private": true,
"scripts": {
"dev": "vite",
diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD
index 40a4d8e6..6e5597d8 100644
--- a/packages/aur/PKGBUILD
+++ b/packages/aur/PKGBUILD
@@ -1,6 +1,6 @@
# Maintainer: stelle
pkgname=psysonic
-pkgver=1.7.2
+pkgver=1.8.0
pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64')
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 36122189..6269ee04 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
-version = "1.7.0"
+version = "1.8.0"
dependencies = [
"biquad",
"md5",
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 47998d7a..eac0075b 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
-version = "1.7.2"
+version = "1.8.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs
index e071bdc2..f8c7037d 100644
--- a/src-tauri/src/audio.rs
+++ b/src-tauri/src/audio.rs
@@ -111,7 +111,7 @@ impl> Source for EqSource {
fn total_duration(&self) -> Option { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
- // Reset biquad filter state to avoid glitches/clicks after seek.
+ // Reset biquad filter state to avoid glitches after seek.
for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db;
@@ -133,41 +133,297 @@ impl> Source for EqSource {
}
}
-// ─── Debug logger ─────────────────────────────────────────────────────────────
+// ─── DynSource — type-erased Source wrapper ───────────────────────────────────
+//
+// Allows chaining differently-typed sources (with trimming applied) into a
+// single concrete type accepted by EqSource>.
-// ─── Engine state (registered as Tauri managed state) ────────────────────────
+struct DynSource {
+ inner: Box + Send>,
+ channels: u16,
+ sample_rate: u32,
+}
+
+impl DynSource {
+ fn new(src: impl Source + Send + 'static) -> Self {
+ let channels = src.channels();
+ let sample_rate = src.sample_rate();
+ Self { inner: Box::new(src), channels, sample_rate }
+ }
+}
+
+impl Iterator for DynSource {
+ type Item = f32;
+ fn next(&mut self) -> Option { self.inner.next() }
+}
+
+impl Source for DynSource {
+ fn current_frame_len(&self) -> Option { self.inner.current_frame_len() }
+ fn channels(&self) -> u16 { self.channels }
+ fn sample_rate(&self) -> u32 { self.sample_rate }
+ fn total_duration(&self) -> Option { self.inner.total_duration() }
+ fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
+ self.inner.try_seek(pos)
+ }
+}
+
+// ─── EqualPowerFadeIn — per-sample sin(t·π/2) fade-in envelope ───────────────
+//
+// Applied to every new track:
+// • Crossfade: fade_dur = crossfade_secs → symmetric equal-power fade-in
+// • Hard cut: fade_dur = 5 ms → micro-fade eliminates DC-click
+// • Gapless: fade_dur = 0 → unity gain (no modification)
+//
+// gain(t) = sin(t · π/2), t ∈ [0, 1)
+// At t = 0 gain = 0, at t = 1 gain = 1.
+// Equal-power property: cos²+sin² = 1 → combined with cos fade-out on Track A
+// the total perceived loudness stays constant across the crossfade.
+
+struct EqualPowerFadeIn> {
+ inner: S,
+ sample_count: u64,
+ fade_samples: u64,
+}
+
+impl> EqualPowerFadeIn {
+ fn new(inner: S, fade_dur: Duration) -> Self {
+ let sample_rate = inner.sample_rate();
+ let channels = inner.channels() as u64;
+ let fade_samples = if fade_dur.is_zero() {
+ 0
+ } else {
+ (fade_dur.as_secs_f64() * sample_rate as f64 * channels as f64) as u64
+ };
+ Self { inner, sample_count: 0, fade_samples }
+ }
+}
+
+impl> Iterator for EqualPowerFadeIn {
+ type Item = f32;
+ fn next(&mut self) -> Option {
+ let sample = self.inner.next()?;
+ let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples {
+ 1.0
+ } else {
+ let t = self.sample_count as f32 / self.fade_samples as f32;
+ (t * std::f32::consts::FRAC_PI_2).sin()
+ };
+ self.sample_count += 1;
+ Some(sample * gain)
+ }
+}
+
+impl> Source for EqualPowerFadeIn {
+ fn current_frame_len(&self) -> Option { 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 { self.inner.total_duration() }
+ fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
+ // Restart the fade envelope after seeking (avoids a mid-song click if
+ // the user seeks to the very beginning while a fade was in progress).
+ self.sample_count = 0;
+ self.inner.try_seek(pos)
+ }
+}
+
+// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ───────
+//
+// This is the key mechanism for gapless: the progress task polls `done` to know
+// exactly when source N has finished inside the Sink, without relying on
+// wall-clock estimation or the unreliable `Sink::empty()`.
+
+struct NotifyingSource> {
+ inner: S,
+ done: Arc,
+ signalled: bool,
+}
+
+impl> NotifyingSource {
+ fn new(inner: S, done: Arc) -> Self {
+ Self { inner, done, signalled: false }
+ }
+}
+
+impl> Iterator for NotifyingSource {
+ type Item = f32;
+ fn next(&mut self) -> Option {
+ let sample = self.inner.next();
+ if sample.is_none() && !self.signalled {
+ self.signalled = true;
+ self.done.store(true, Ordering::SeqCst);
+ }
+ sample
+ }
+}
+
+impl> Source for NotifyingSource {
+ fn current_frame_len(&self) -> Option { 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 { self.inner.total_duration() }
+ fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> {
+ // If we seek backwards the source is no longer exhausted.
+ self.signalled = false;
+ self.done.store(false, Ordering::SeqCst);
+ self.inner.try_seek(pos)
+ }
+}
+
+// ─── Encoder-gap trimming (iTunSMPB) ─────────────────────────────────────────
+//
+// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent
+// samples for LAME) and append end-padding to fill the final frame.
+// iTunes embeds the exact counts in an ID3v2 COMM frame with description
+// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex)
+//
+// Parsing strategy: scan raw bytes for the ASCII marker, then extract the
+// first whitespace-separated hex tokens after it.
+
+struct GaplessInfo {
+ delay_samples: u64,
+ total_valid_samples: Option,
+}
+
+impl Default for GaplessInfo {
+ fn default() -> Self {
+ Self { delay_samples: 0, total_valid_samples: None }
+ }
+}
+
+fn find_subsequence(data: &[u8], needle: &[u8]) -> Option {
+ data.windows(needle.len()).position(|w| w == needle)
+}
+
+fn parse_gapless_info(data: &[u8]) -> GaplessInfo {
+ let pos = match find_subsequence(data, b"iTunSMPB") {
+ Some(p) => p,
+ None => return GaplessInfo::default(),
+ };
+
+ // Collect printable ASCII bytes after the tag (skip nulls / control chars)
+ let tail = &data[pos + 8..data.len().min(pos + 8 + 256)];
+ let text: String = tail.iter()
+ .map(|&b| b as char)
+ .filter(|c| c.is_ascii_hexdigit() || *c == ' ')
+ .collect();
+
+ let parts: Vec<&str> = text.split_whitespace().collect();
+ // parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total
+ if parts.len() < 3 {
+ return GaplessInfo::default();
+ }
+ let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0);
+ let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0);
+ let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok());
+
+ let total_valid = total_raw.map(|t| t).filter(|&t| t > 0).or_else(|| {
+ // Derive from delay + padding if total not available:
+ // Not possible without knowing total encoded samples, so just use None.
+ let _ = padding;
+ None
+ });
+
+ GaplessInfo { delay_samples: delay, total_valid_samples: total_valid }
+}
+
+/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify.
+///
+/// `fade_in_dur`:
+/// • `Duration::ZERO` — unity gain; used for gapless chain (no click)
+/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click)
+/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade
+fn build_source(
+ data: Vec,
+ duration_hint: f64,
+ eq_gains: Arc<[AtomicU32; 10]>,
+ eq_enabled: Arc,
+ done_flag: Arc,
+ fade_in_dur: Duration,
+) -> Result<(NotifyingSource>>, f64), String> {
+ let gapless = parse_gapless_info(&data);
+
+ let cursor = Cursor::new(data);
+ let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?;
+ let sample_rate = decoder.sample_rate();
+
+ // Determine effective duration.
+ // Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3).
+ let effective_dur = if duration_hint > 1.0 {
+ duration_hint
+ } else {
+ decoder.total_duration()
+ .map(|d| d.as_secs_f64())
+ .unwrap_or(duration_hint)
+ };
+
+ // Apply encoder-delay trim and optional end-padding trim.
+ let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() {
+ let delay_dur = Duration::from_secs_f64(
+ gapless.delay_samples as f64 / sample_rate as f64
+ );
+ let base = decoder.convert_samples::().skip_duration(delay_dur);
+
+ if let Some(total) = gapless.total_valid_samples {
+ let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64);
+ DynSource::new(base.take_duration(valid_dur))
+ } else {
+ DynSource::new(base)
+ }
+ } else {
+ DynSource::new(decoder.convert_samples::())
+ };
+
+ let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled);
+ let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur);
+ let notifying = NotifyingSource::new(fade_in, done_flag);
+
+ Ok((notifying, effective_dur))
+}
+
+// ─── Engine state ─────────────────────────────────────────────────────────────
pub(crate) struct PreloadedTrack {
url: String,
data: Vec,
- duration_hint: f64,
+}
+
+/// Info about the track that has been appended (chained) to the current Sink
+/// but whose source has not yet started playing (gapless mode only).
+struct ChainedInfo {
+ /// The URL that was chained — used by audio_play to detect a pre-chain hit.
+ url: String,
+ duration_secs: f64,
+ replay_gain_linear: f32,
+ base_volume: f32,
+ /// Set by NotifyingSource when this chained track's source is exhausted.
+ source_done: Arc,
}
pub struct AudioEngine {
pub stream_handle: Arc,
pub current: Arc>,
- /// Monotonically incremented on each audio_play / audio_stop call.
- /// The background progress task captures its own `gen` at creation and
- /// bails out if this counter has moved on, preventing stale events.
+ /// Monotonically incremented on each audio_play (non-chain) / audio_stop call.
pub generation: Arc,
pub http_client: reqwest::Client,
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc,
pub preloaded: Arc>>,
pub crossfade_enabled: Arc,
- pub crossfade_secs: Arc, // f32 stored as bits
+ pub crossfade_secs: Arc,
pub fading_out_sink: Arc>>,
+ /// When true, audio_play chains sources to the existing Sink instead of
+ /// creating a new one, achieving sample-accurate gapless transitions.
+ pub gapless_enabled: Arc,
+ /// Info about the next-up chained track (gapless mode).
+ /// The progress task reads this when `current_source_done` fires.
+ pub chained_info: Arc>>,
}
pub struct AudioCurrent {
- /// The active rodio Sink. `None` when stopped.
pub sink: Option,
pub duration_secs: f64,
- /// Position (seconds) that we seeked/resumed from.
pub seek_offset: f64,
- /// Instant when we started counting from seek_offset (None when paused/stopped).
pub play_started: Option,
- /// Set when paused; holds the position at pause time.
pub paused_at: Option,
pub replay_gain_linear: f32,
pub base_volume: f32,
@@ -187,9 +443,6 @@ impl AudioCurrent {
}
}
-/// Initialise the audio engine. Spawns a dedicated thread that holds the
-/// `OutputStream` alive for the lifetime of the process (parking prevents
-/// the thread — and thus the stream — from being dropped).
pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
let (tx, rx) = std::sync::mpsc::sync_channel::(0);
@@ -198,14 +451,9 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
.spawn(move || match rodio::OutputStream::try_default() {
Ok((_stream, handle)) => {
tx.send(handle).ok();
- // Park forever — `_stream` must stay alive for audio to work.
- loop {
- std::thread::park();
- }
- }
- Err(e) => {
- eprintln!("[psysonic] audio output error: {e}");
+ loop { std::thread::park(); }
}
+ Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
})
.expect("spawn audio stream thread");
@@ -233,6 +481,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
crossfade_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
fading_out_sink: Arc::new(Mutex::new(None)),
+ gapless_enabled: Arc::new(AtomicBool::new(false)),
+ chained_info: Arc::new(Mutex::new(None)),
};
(engine, thread)
@@ -246,11 +496,59 @@ pub struct ProgressPayload {
pub duration: f64,
}
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Fetch track bytes from the preload cache or via HTTP.
+async fn fetch_data(
+ url: &str,
+ state: &AudioEngine,
+ gen: u64,
+ app: &AppHandle,
+) -> Result