feat: v1.8.0 — new themes, queue toolbar, lightbox, i18n (fr/nl)

Themes:
- Add Poison (phosphor green on charcoal), Nucleo (warm brass light), Classic Winamp (LCD glow, Winamp yellow/orange) themes
- Overhaul Psychowave — refined deep violet, no longer WIP
- Reorganise ThemePicker into groups: Catppuccin, Nord, Retro, Tokyo Night, Psysonic Themes
- Add --volume-accent CSS var for per-theme volume slider colour override

Queue:
- Full toolbar redesign with centred round buttons (Shuffle/Save/Load/Clear/Gapless/Crossfade)
- Crossfade popover with 1–10 s range slider, right-aligned to avoid viewport overflow
- Queue header: title + count + duration inline in accent colour, close button removed
- Tech info (codec/bitrate) as frosted-glass overlay badge on cover art

UI:
- CoverLightbox shared component for album cover (AlbumHeader) and artist avatar (ArtistDetail)
- NowPlayingDropdown: separate spinning/loading state — button always clickable, icon spins 600 ms min
- Settings: "Experimental" badge on Crossfade and Gapless toggles
- Help page: 2-column grid layout, new Crossfade & Gapless Q&A entry, updated theme/language entries

i18n:
- Full French (fr) and Dutch (nl) translations across all namespaces
- Language selector sorted alphabetically (nl, en, fr, de)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-03-21 01:05:16 +01:00
parent c9c68a0e57
commit 0b1ed8cc5a
32 changed files with 3378 additions and 504 deletions
+51
View File
@@ -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 (110 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
+37 -19
View File
@@ -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 `<html>` |
| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `<html>` |
| `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), `<TooltipPortal />` 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 110 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<T>()` 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 `<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.
21 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.
| 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
+4 -6
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "psysonic",
"version": "1.7.2",
"version": "1.8.0",
"private": true,
"scripts": {
"dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev>
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')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]]
name = "psysonic"
version = "1.7.0"
version = "1.8.0"
dependencies = [
"biquad",
"md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "psysonic"
version = "1.7.2"
version = "1.8.0"
description = "Psysonic Desktop Music Player"
authors = []
license = ""
+617 -160
View File
@@ -111,7 +111,7 @@ impl<S: Source<Item = f32>> Source for EqSource<S> {
fn total_duration(&self) -> Option<Duration> { 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<S: Source<Item = f32>> Source for EqSource<S> {
}
}
// ─── Debug logger ─────────────────────────────────────────────────────────────
// ─── DynSource — type-erased Source wrapper ───────────────────────────────────
//
// Allows chaining differently-typed sources (with trimming applied) into a
// single concrete type accepted by EqSource<S: Source<Item=f32>>.
// ─── Engine state (registered as Tauri managed state) ────────────────────────
struct DynSource {
inner: Box<dyn Source<Item = f32> + Send>,
channels: u16,
sample_rate: u32,
}
impl DynSource {
fn new(src: impl Source<Item = f32> + 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<f32> { self.inner.next() }
}
impl Source for DynSource {
fn current_frame_len(&self) -> Option<usize> { self.inner.current_frame_len() }
fn channels(&self) -> u16 { self.channels }
fn sample_rate(&self) -> u32 { self.sample_rate }
fn total_duration(&self) -> Option<Duration> { 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<S: Source<Item = f32>> {
inner: S,
sample_count: u64,
fade_samples: u64,
}
impl<S: Source<Item = f32>> EqualPowerFadeIn<S> {
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<S: Source<Item = f32>> Iterator for EqualPowerFadeIn<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
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<S: Source<Item = f32>> Source for EqualPowerFadeIn<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> {
// 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<S: Source<Item = f32>> {
inner: S,
done: Arc<AtomicBool>,
signalled: bool,
}
impl<S: Source<Item = f32>> NotifyingSource<S> {
fn new(inner: S, done: Arc<AtomicBool>) -> Self {
Self { inner, done, signalled: false }
}
}
impl<S: Source<Item = f32>> Iterator for NotifyingSource<S> {
type Item = f32;
fn next(&mut self) -> Option<f32> {
let sample = self.inner.next();
if sample.is_none() && !self.signalled {
self.signalled = true;
self.done.store(true, Ordering::SeqCst);
}
sample
}
}
impl<S: Source<Item = f32>> Source for NotifyingSource<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 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 5762112 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<u64>,
}
impl Default for GaplessInfo {
fn default() -> Self {
Self { delay_samples: 0, total_valid_samples: None }
}
}
fn find_subsequence(data: &[u8], needle: &[u8]) -> Option<usize> {
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<u8>,
duration_hint: f64,
eq_gains: Arc<[AtomicU32; 10]>,
eq_enabled: Arc<AtomicBool>,
done_flag: Arc<AtomicBool>,
fade_in_dur: Duration,
) -> Result<(NotifyingSource<EqualPowerFadeIn<EqSource<DynSource>>>, 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::<f32>().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::<f32>())
};
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<u8>,
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<AtomicBool>,
}
pub struct AudioEngine {
pub stream_handle: Arc<rodio::OutputStreamHandle>,
pub current: Arc<Mutex<AudioCurrent>>,
/// 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<AtomicU64>,
pub http_client: reqwest::Client,
pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>,
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
pub crossfade_enabled: Arc<AtomicBool>,
pub crossfade_secs: Arc<AtomicU32>, // f32 stored as bits
pub crossfade_secs: Arc<AtomicU32>,
pub fading_out_sink: Arc<Mutex<Option<Sink>>>,
/// 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<AtomicBool>,
/// Info about the next-up chained track (gapless mode).
/// The progress task reads this when `current_source_done` fires.
pub chained_info: Arc<Mutex<Option<ChainedInfo>>>,
}
pub struct AudioCurrent {
/// The active rodio Sink. `None` when stopped.
pub sink: Option<Sink>,
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<Instant>,
/// Set when paused; holds the position at pause time.
pub paused_at: Option<f64>,
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::<rodio::OutputStreamHandle>(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<Option<Vec<u8>>, String> {
// Check preload cache first.
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
preloaded.take().map(|p| p.data)
} else {
None
}
};
if let Some(data) = cached {
return Ok(Some(data));
}
let response = state.http_client.get(url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(None); // superseded
}
let status = response.status().as_u16();
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
Ok(Some(data))
}
fn compute_gain(
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
volume: f32,
) -> (f32, f32) {
let gain_linear = replay_gain_db
.map(|db| 10f32.powf(db / 20.0))
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
let gain_linear = gain_linear.min(1.0 / peak);
let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
(gain_linear, effective)
}
// ─── Commands ─────────────────────────────────────────────────────────────────
/// Download and play the given URL. Replaces any currently playing track.
/// Emits `audio:playing` (with duration as f64) once playback starts,
/// then `audio:progress` every 500 ms, and `audio:ended` when done.
#[tauri::command]
pub async fn audio_play(
url: String,
@@ -261,115 +559,116 @@ pub async fn audio_play(
app: AppHandle,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Claim this generation — any in-flight progress task with the old gen will exit.
let gapless = state.gapless_enabled.load(Ordering::Relaxed);
// ── Gapless pre-chain hit ─────────────────────────────────────────────────
// audio_chain_preload already appended this URL to the Sink 30 s in
// advance. The source is live in the queue — just return and let the
// progress task handle the state transition when the previous source ends.
if gapless {
let already_chained = state.chained_info.lock().unwrap()
.as_ref()
.map(|c| c.url == url)
.unwrap_or(false);
if already_chained {
return Ok(());
}
}
// ── Standard (new-sink) path ─────────────────────────────────────────────
// Used for: manual skip, gapless OFF, first play, or gapless when the
// proactive chain was not set up in time.
let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Stop any previous fading sink unconditionally.
{
let mut fo = state.fading_out_sink.lock().unwrap();
if let Some(old) = fo.take() {
// Cancel any pending chain (manual skip while gapless chain was set up).
*state.chained_info.lock().unwrap() = None;
// Stop fading-out sink from previous crossfade.
if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
old.stop();
}
}
// NOTE: We intentionally do NOT stop the current Sink here.
// The old Sink keeps playing while we download + decode the new track.
// We swap Sinks atomically only after the new data is ready, so the
// old track plays to completion (or as close to it as possible).
// ── Check preload cache or download ──────────────────────────────────────
let data: Vec<u8> = {
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) {
let d = p.data.clone();
let _ = p.duration_hint; // used for type check
*preloaded = None;
Some(d)
} else {
None
}
};
if let Some(cached_data) = cached {
cached_data
} else {
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() {
let status = response.status().as_u16();
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let msg = format!("HTTP {status}");
app.emit("audio:error", &msg).ok();
return Err(msg);
}
response.bytes().await.map_err(|e| e.to_string())?.into()
}
// Fetch bytes (may use preload cache).
let data = match fetch_data(&url, &state, gen, &app).await? {
Some(d) => d,
None => return Ok(()), // superseded while downloading
};
// Bail if superseded while downloading.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
// ── Decode ────────────────────────────────────────────────────────────────
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
// Trust the Subsonic API duration_hint as the primary source.
// Decoder::total_duration() is unreliable for VBR MP3 (symphonia may
// return a single-frame or header duration that is far too short).
let decoder_duration = {
let cursor = Cursor::new(data.clone());
Decoder::new(cursor)
.ok()
.and_then(|d| d.total_duration())
.map(|d| d.as_secs_f64())
};
let duration_secs = if duration_hint > 1.0 {
duration_hint
} else {
decoder_duration.unwrap_or(duration_hint)
};
let cursor = Cursor::new(data);
let decoder = Decoder::new(cursor).map_err(|e| {
app.emit("audio:error", e.to_string()).ok();
e.to_string()
})?;
// Final generation check before committing.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
// ── Compute replay gain ───────────────────────────────────────────────────
let gain_linear: f32 = replay_gain_db
.map(|db| 10f32.powf(db / 20.0))
.unwrap_or(1.0);
let peak = replay_gain_peak.unwrap_or(1.0).max(0.001);
// Limit gain so peak sample doesn't exceed 1.0 (prevent clipping).
let gain_linear = gain_linear.min(1.0 / peak);
let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0);
// ── Create new sink ───────────────────────────────────────────────────────
let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed);
let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0);
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
sink.set_volume(effective_volume);
let eq_source = EqSource::new(
decoder.convert_samples::<f32>(),
// Measure how much audio Track A actually has left right now.
// By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
// have consumed ~500800ms from Track A's tail — so its true remaining time
// is always less than crossfade_secs_val. Using the measured remaining time
// for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and
// guarantees Track A reaches 0 exactly when its source exhausts.
let actual_fade_secs: f32 = if crossfade_enabled {
let cur = state.current.lock().unwrap();
let remaining = (cur.duration_secs - cur.position()) as f32;
remaining.clamp(0.1, crossfade_secs_val)
} else {
0.0
};
// Fade-in duration for Track B:
// crossfade → equal-power sin(t·π/2) over actual remaining time of Track A
// hard cut → 5 ms micro-fade to suppress DC-offset click
let fade_in_dur = if crossfade_enabled {
Duration::from_secs_f32(actual_fade_secs)
} else {
Duration::from_millis(5)
};
// Build source: decode → trim → EQ → fade-in → notify.
let done_flag = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
);
sink.append(eq_source);
done_flag.clone(),
fade_in_dur,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
// Atomically swap sinks: take old one, install new one.
// Capture old volume so the crossfade fade-out starts at the right level.
if state.generation.load(Ordering::SeqCst) != gen {
return Ok(());
}
let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?;
sink.set_volume(effective_volume);
// Gapless OFF: prepend a short silence so tracks are clearly separated.
// Only when this is an auto-advance (near end), not on manual skip.
if !gapless {
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
};
let cur_dur = {
let cur = state.current.lock().unwrap();
cur.duration_secs
};
let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0;
if is_auto_advance {
let silence = rodio::source::Zero::<f32>::new(
source.channels(),
source.sample_rate(),
).take_duration(Duration::from_millis(500));
sink.append(silence);
}
}
sink.append(source);
// Atomically swap sinks.
let (old_sink, old_vol) = {
let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0);
@@ -384,25 +683,35 @@ pub async fn audio_play(
(old, old_vol)
};
// Handle old sink: crossfade fade-out or immediate stop.
// The new track is already playing; the old sink runs in parallel during a crossfade.
// Handle old sink: equal-power crossfade or immediate stop.
if crossfade_enabled {
if let Some(old) = old_sink {
// Park the old sink in fading_out_sink so a subsequent audio_play can cancel it.
*state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone();
tokio::spawn(async move {
let steps: u32 = 30;
let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64;
for i in (0..=steps).rev() {
{
// ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
// Duration = actual_fade_secs (Track A's measured remaining time),
// so the fade reaches exactly 0 when the source is exhausted.
const STEP_MS: u64 = 10;
let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32;
for i in (0..=total_steps).rev() {
let alive = {
let fo = fo_arc.lock().unwrap();
match fo.as_ref() {
Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)),
None => return, // cancelled by a subsequent audio_play
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,
}
tokio::time::sleep(Duration::from_millis(step_ms)).await;
// 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() {
s.stop();
@@ -416,24 +725,141 @@ pub async fn audio_play(
app.emit("audio:playing", duration_secs).ok();
// ── Progress + ended detection ────────────────────────────────────────────
// We do NOT use `sink.empty()` because in rodio 0.19 the source moves from
// the pending queue to the active state almost immediately after `append()`,
// making `empty()` return `true` within milliseconds even for long tracks.
//
// Instead we use the wall-clock position (seek_offset + elapsed).
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it
// reaches the end it stays there. We fire `audio:ended` after two
// consecutive ticks where position >= duration - threshold, where threshold
// is extended to crossfade_secs when crossfade is enabled so the frontend
// gets time to start the next track during the fade.
let gen_counter = state.generation.clone();
let current_arc = state.current.clone();
let app_clone = app.clone();
let crossfade_enabled_arc = state.crossfade_enabled.clone();
let crossfade_secs_arc = state.crossfade_secs.clone();
spawn_progress_task(
gen,
state.generation.clone(),
state.current.clone(),
state.chained_info.clone(),
state.crossfade_enabled.clone(),
state.crossfade_secs.clone(),
done_flag,
app,
);
Ok(())
}
/// Proactively appends the next track to the current Sink ~30 s before the
/// current track ends. Called from JS at the same trigger point as preload.
///
/// Because this runs well before the track boundary, the IPC round-trip is
/// irrelevant — by the time the current track actually ends, the next source
/// is already live in the Sink queue and rodio transitions at sample accuracy.
///
/// audio_play() checks chained_info.url on arrival: if it matches, it returns
/// immediately without touching the Sink (pure no-op on the audio path).
#[tauri::command]
pub async fn audio_chain_preload(
url: String,
volume: f32,
duration_hint: f64,
replay_gain_db: Option<f32>,
replay_gain_peak: Option<f32>,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Idempotent: already chained this URL → nothing to do.
{
let chained = state.chained_info.lock().unwrap();
if chained.as_ref().map(|c| c.url == url).unwrap_or(false) {
return Ok(());
}
}
// Gapless must be enabled and a sink must exist.
if !state.gapless_enabled.load(Ordering::Relaxed) {
return Ok(());
}
let snapshot_gen = state.generation.load(Ordering::SeqCst);
// Fetch bytes — use preload cache if available, otherwise HTTP.
let data: Vec<u8> = {
let cached = {
let mut preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
preloaded.take().map(|p| p.data)
} else {
None
}
};
if let Some(d) = cached {
d
} else {
let resp = state.http_client.get(&url).send().await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Ok(()); // silently fail — audio_play will retry
}
resp.bytes().await.map_err(|e| e.to_string())?.into()
}
};
// Bail if the user skipped to a different track while we were downloading.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume);
let done_next = Arc::new(AtomicBool::new(false));
let (source, duration_secs) = build_source(
data,
duration_hint,
state.eq_gains.clone(),
state.eq_enabled.clone(),
done_next.clone(),
Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click
).map_err(|e| e.to_string())?;
// Final gen check — reject if a manual skip happened during decode.
if state.generation.load(Ordering::SeqCst) != snapshot_gen {
return Ok(());
}
// Append to the existing Sink. The audio hardware stream never stalls.
{
let cur = state.current.lock().unwrap();
match &cur.sink {
Some(sink) => {
sink.set_volume(effective_volume);
sink.append(source);
}
None => return Ok(()), // playback stopped — bail
}
}
*state.chained_info.lock().unwrap() = Some(ChainedInfo {
url,
duration_secs,
replay_gain_linear: gain_linear,
base_volume: volume.clamp(0.0, 1.0),
source_done: done_next,
});
Ok(())
}
/// Spawns the per-generation progress + ended-detection task.
///
/// The task owns a local `done: Arc<AtomicBool>` reference that starts as
/// the current track's done flag. When the progress task detects that 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
/// task or changing the generation counter.
fn spawn_progress_task(
gen: u64,
gen_counter: Arc<AtomicU64>,
current_arc: Arc<Mutex<AudioCurrent>>,
chained_arc: Arc<Mutex<Option<ChainedInfo>>>,
crossfade_enabled_arc: Arc<AtomicBool>,
crossfade_secs_arc: Arc<AtomicU32>,
initial_done: Arc<AtomicBool>,
app: AppHandle,
) {
tokio::spawn(async move {
let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done;
loop {
tokio::time::sleep(Duration::from_millis(500)).await;
@@ -442,20 +868,43 @@ pub async fn audio_play(
break;
}
// ── Gapless transition detection ─────────────────────────────────
// If the current source is exhausted AND we have a chained track
// ready, transition seamlessly: swap tracking state, emit
// audio:playing for the new track, and continue the loop.
if current_done.load(Ordering::SeqCst) {
let chained = chained_arc.lock().unwrap().take();
if let Some(info) = chained {
// Swap to the chained source's done flag.
current_done = info.source_done;
// Tracking was already updated in audio_play_gapless_chain;
// just update replay gain fields in case they differ.
{
let mut cur = current_arc.lock().unwrap();
cur.replay_gain_linear = info.replay_gain_linear;
cur.base_volume = info.base_volume;
// Reset play_started to now — the old track physically
// ended, the new one is now actively producing samples.
cur.seek_offset = 0.0;
cur.play_started = Some(Instant::now());
}
app.emit("audio:playing", info.duration_secs).ok();
near_end_ticks = 0;
continue;
}
// Current source exhausted but no chain queued — the Sink is
// likely draining; audio:ended will fire on the next tick via
// the near-end logic below.
}
let (pos, dur, is_paused) = {
let cur = current_arc.lock().unwrap();
(cur.position(), cur.duration_secs, cur.paused_at.is_some())
};
app_clone
.emit(
"audio:progress",
ProgressPayload { current_time: pos, duration: dur },
)
.ok();
app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
if is_paused {
// Don't advance near-end counter while paused (stay put).
continue;
}
@@ -467,7 +916,7 @@ pub async fn audio_play(
near_end_ticks += 1;
if near_end_ticks >= 2 {
gen_counter.fetch_add(1, Ordering::SeqCst);
app_clone.emit("audio:ended", ()).ok();
app.emit("audio:ended", ()).ok();
break;
}
} else {
@@ -475,8 +924,6 @@ pub async fn audio_play(
}
}
});
Ok(())
}
#[tauri::command]
@@ -509,6 +956,7 @@ pub fn audio_resume(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_stop(state: State<'_, AudioEngine>) {
state.generation.fetch_add(1, Ordering::SeqCst);
*state.chained_info.lock().unwrap() = None;
let mut cur = state.current.lock().unwrap();
if let Some(sink) = cur.sink.take() {
sink.stop();
@@ -521,6 +969,15 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
// Seeking far back invalidates any pending gapless chain.
let cur_pos = {
let cur = state.current.lock().unwrap();
cur.position()
};
if seconds < cur_pos - 1.0 {
*state.chained_info.lock().unwrap() = None;
}
let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink {
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
@@ -558,24 +1015,19 @@ pub async fn audio_preload(
duration_hint: f64,
state: State<'_, AudioEngine>,
) -> Result<(), String> {
// Don't re-download if already preloaded for this URL.
{
let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
return Ok(());
}
}
let response = state
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
if !response.status().is_success() {
return Ok(()); // silently fail preload
return Ok(());
}
let data: Vec<u8> = response.bytes().await.map_err(|e| e.to_string())?.into();
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint });
let _ = duration_hint; // kept in API for compatibility
*state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data });
Ok(())
}
@@ -584,3 +1036,8 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin
state.crossfade_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed);
}
#[tauri::command]
pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) {
state.gapless_enabled.store(enabled, Ordering::Relaxed);
}
+2
View File
@@ -184,6 +184,8 @@ pub fn run() {
audio::audio_set_eq,
audio::audio_preload,
audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
lastfm_request,
])
.run(tauri::generate_context!())
+3 -3
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic",
"version": "1.7.2",
"version": "1.8.0",
"identifier": "dev.psysonic.player",
"build": {
"beforeDevCommand": "npm run dev",
@@ -16,8 +16,8 @@
"title": "Psysonic",
"width": 1280,
"height": 800,
"minWidth": 900,
"minHeight": 600,
"minWidth": 1280,
"minHeight": 720,
"resizable": true,
"fullscreen": false,
"decorations": true,
+2
View File
@@ -32,6 +32,7 @@ import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal';
import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay';
import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore';
@@ -140,6 +141,7 @@ function AppShell() {
<LiveSearch />
<div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown />
<button
className="collapse-btn"
+13 -1
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
const API_KEY = '9917fb39049225a13bec225ad6d49054';
const API_SECRET = '03817dda02bee87a178aab7581abae3b';
@@ -15,13 +16,24 @@ function errMsg(e: unknown): string {
async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
const entries = Object.entries(params) as [string, string][];
return invoke('lastfm_request', {
try {
const result = await invoke('lastfm_request', {
params: entries,
sign,
get,
apiKey: API_KEY,
apiSecret: API_SECRET,
});
// Clear session error on any successful authenticated call
if (sign) useAuthStore.getState().setLastfmSessionError(false);
return result;
} catch (e) {
// Last.fm error codes 4, 9, 14 = auth/session invalid
if (sign && /^Last\.fm (4|9|14)\b/.test(errMsg(e))) {
useAuthStore.getState().setLastfmSessionError(true);
}
throw e;
}
}
export async function lastfmGetToken(): Promise<string> {
+19 -2
View File
@@ -1,8 +1,9 @@
import React from 'react';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react';
import { SubsonicSong } from '../api/subsonic';
import { SubsonicSong, buildCoverArtUrl } from '../api/subsonic';
import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string {
@@ -49,6 +50,7 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
);
}
interface AlbumInfo {
id: string;
name: string;
@@ -97,6 +99,7 @@ export default function AlbumHeader({
}: AlbumHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const [lightboxOpen, setLightboxOpen] = useState(false);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
@@ -104,6 +107,13 @@ export default function AlbumHeader({
return (
<>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />}
{lightboxOpen && info.coverArt && (
<CoverLightbox
src={buildCoverArtUrl(info.coverArt, 2000)}
alt={`${info.name} Cover`}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="album-detail-header">
{resolvedCoverUrl && (
@@ -121,7 +131,14 @@ export default function AlbumHeader({
</button>
<div className="album-detail-hero">
{coverUrl ? (
<button
className="album-detail-cover-btn"
onClick={() => setLightboxOpen(true)}
data-tooltip="Vergrößern"
aria-label={`${info.name} Cover vergrößern`}
>
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} />
</button>
) : (
<div className="album-detail-cover album-cover-placeholder"></div>
)}
+12 -4
View File
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ConnectionStatus } from '../hooks/useConnectionStatus';
interface Props {
@@ -9,17 +10,24 @@ interface Props {
export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
const { t } = useTranslation();
const navigate = useNavigate();
const label = isLan ? 'LAN' : t('connection.extern');
const title =
const tooltip =
status === 'connected'
? t('connection.connected')
? t('connection.connectedTo', { server: serverName })
: status === 'disconnected'
? t('connection.disconnected')
? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking');
return (
<div className="connection-indicator" data-tooltip={title} data-tooltip-pos="bottom">
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
<div className={`connection-led connection-led--${status}`} />
<div className="connection-meta">
<span className="connection-type">{label}</span>
+28
View File
@@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import { X } from 'lucide-react';
interface Props {
src: string;
alt: string;
onClose: () => void;
}
export default function CoverLightbox({ src, alt, onClose }: Props) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div className="cover-lightbox-overlay" onClick={onClose} role="dialog" aria-modal="true" aria-label={alt}>
<button className="cover-lightbox-close" onClick={onClose} aria-label="Close"><X size={20} /></button>
<img
className="cover-lightbox-img"
src={src}
alt={alt}
onClick={e => e.stopPropagation()}
/>
</div>
);
}
+39
View File
@@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
import LastfmIcon from './LastfmIcon';
export default function LastfmIndicator() {
const { t } = useTranslation();
const navigate = useNavigate();
const { lastfmSessionKey, lastfmUsername, lastfmSessionError } = useAuthStore();
if (!lastfmSessionKey) return null;
const tooltip = lastfmSessionError
? t('connection.lastfmSessionInvalid')
: t('connection.lastfmConnected', { user: lastfmUsername });
return (
<div
className="connection-indicator"
style={{ cursor: 'pointer' }}
onClick={() => navigate('/settings', { state: { tab: 'server' } })}
data-tooltip={tooltip}
data-tooltip-pos="bottom"
>
<div
className={`connection-led connection-led--${lastfmSessionError ? 'disconnected' : 'connected'}`}
/>
<div className="connection-meta">
<span className="connection-type" style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<LastfmIcon size={11} />
Last.fm
</span>
<span className="connection-server">
{lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`}
</span>
</div>
</div>
);
}
+12 -4
View File
@@ -14,6 +14,7 @@ export default function NowPlayingDropdown() {
const isPlaying = usePlayerStore(s => s.isPlaying);
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false);
const [spinning, setSpinning] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const fetchNowPlaying = async () => {
@@ -28,6 +29,13 @@ export default function NowPlayingDropdown() {
}
};
const handleRefresh = () => {
setSpinning(true);
fetchNowPlaying().finally(() => {
setTimeout(() => setSpinning(false), 600);
});
};
// Poll in background so the badge stays current without opening the dropdown
useEffect(() => {
fetchNowPlaying();
@@ -104,11 +112,11 @@ export default function NowPlayingDropdown() {
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid var(--border-subtle)', paddingBottom: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button
onClick={fetchNowPlaying}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`}
style={{ width: '28px', height: '28px', padding: 0 }}
onClick={handleRefresh}
className="btn btn-ghost"
style={{ width: '28px', height: '28px', padding: 0, justifyContent: 'center' }}
>
<RefreshCw size={14} />
<RefreshCw size={14} className={spinning ? 'animate-spin' : ''} />
</button>
</div>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function PlayerBar() {
}, [setVolume]);
const volumeStyle = {
background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`,
};
return (
+103 -32
View File
@@ -1,10 +1,11 @@
import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react';
import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -130,6 +131,30 @@ export default function QueuePanel() {
const enqueue = usePlayerStore(s => s.enqueue);
const contextMenu = usePlayerStore(s => s.contextMenu);
const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled);
const crossfadeSecs = useAuthStore(s => s.crossfadeSecs);
const gaplessEnabled = useAuthStore(s => s.gaplessEnabled);
const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled);
const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs);
const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled);
const [showCrossfadePopover, setShowCrossfadePopover] = useState(false);
const crossfadeBtnRef = useRef<HTMLButtonElement>(null);
const crossfadePopoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showCrossfadePopover) return;
const handle = (e: MouseEvent) => {
if (
crossfadeBtnRef.current?.contains(e.target as Node) ||
crossfadePopoverRef.current?.contains(e.target as Node)
) return;
setShowCrossfadePopover(false);
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, [showCrossfadePopover]);
const [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false);
@@ -255,8 +280,8 @@ export default function QueuePanel() {
}}
>
<div className="queue-header">
<div>
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2>
<div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600);
@@ -266,31 +291,12 @@ export default function QueuePanel() {
? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`;
return (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginTop: '2px' }}>
<span style={{ fontSize: '13px', color: 'var(--accent)', whiteSpace: 'nowrap' }}>
{queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</div>
</span>
);
})()}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button onClick={() => shuffleQueue()} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.shuffle')} data-tooltip={t('queue.shuffle')} disabled={queue.length < 2}>
<Shuffle size={14} />
</button>
<button onClick={handleSave} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.savePlaylist')} data-tooltip={t('queue.save')}>
<Save size={14} />
</button>
<button onClick={handleLoad} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.loadPlaylist')} data-tooltip={t('queue.load')}>
<FolderOpen size={14} />
</button>
<button onClick={handleClear} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.clear')} data-tooltip={t('queue.clear')}>
<Trash2 size={14} />
</button>
<div style={{ width: '1px', height: '14px', background: 'var(--border)', margin: '0 4px' }} />
<button onClick={toggleQueue} style={{ color: 'var(--text-muted)', cursor: 'pointer', display: 'flex' }} aria-label={t('queue.close')} data-tooltip={t('queue.hide')}>
<X size={16} />
</button>
</div>
</div>
{currentTrack && (
@@ -301,6 +307,13 @@ export default function QueuePanel() {
) : (
<div className="fallback"><Music size={32} /></div>
)}
{(currentTrack.bitRate || currentTrack.suffix) && (
<div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix
? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}`
: currentTrack.suffix?.toUpperCase() ?? ''}
</div>
)}
</div>
<div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
@@ -319,19 +332,77 @@ export default function QueuePanel() {
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}>
<div className="queue-current-tech">
{currentTrack.bitRate && currentTrack.suffix ? (
`${currentTrack.bitRate} kbps · ${currentTrack.suffix.toUpperCase()}`
) : (
currentTrack.suffix?.toUpperCase() ?? ''
)}
</div>
{renderStars(currentTrack.userRating)}
</div>
</div>
)}
<div className="queue-toolbar">
<button className="queue-round-btn" onClick={() => shuffleQueue()} disabled={queue.length < 2} data-tooltip={t('queue.shuffle')} aria-label={t('queue.shuffle')}>
<Shuffle size={13} />
</button>
<button className="queue-round-btn" onClick={handleSave} data-tooltip={t('queue.savePlaylist')} aria-label={t('queue.savePlaylist')}>
<Save size={13} />
</button>
<button className="queue-round-btn" onClick={handleLoad} data-tooltip={t('queue.loadPlaylist')} aria-label={t('queue.loadPlaylist')}>
<FolderOpen size={13} />
</button>
<button className="queue-round-btn" onClick={handleClear} data-tooltip={t('queue.clear')} aria-label={t('queue.clear')}>
<Trash2 size={13} />
</button>
<div className="queue-toolbar-sep" />
<button
className={`queue-round-btn${gaplessEnabled ? ' active' : ''}`}
onClick={() => setGaplessEnabled(!gaplessEnabled)}
data-tooltip={t('queue.gapless')}
aria-label={t('queue.gapless')}
>
<Infinity size={13} />
</button>
<div style={{ position: 'relative' }}>
<button
ref={crossfadeBtnRef}
className={`queue-round-btn${crossfadeEnabled || showCrossfadePopover ? ' active' : ''}`}
onClick={() => {
if (crossfadeEnabled) {
setCrossfadeEnabled(false);
setShowCrossfadePopover(false);
} else {
setCrossfadeEnabled(true);
setShowCrossfadePopover(true);
}
}}
data-tooltip={showCrossfadePopover ? undefined : t('queue.crossfade')}
aria-label={t('queue.crossfade')}
>
<Waves size={13} />
</button>
{showCrossfadePopover && (
<div className="crossfade-popover" ref={crossfadePopoverRef}>
<div className="crossfade-popover-label">
<Waves size={11} />
{t('queue.crossfade')}
<span className="crossfade-popover-value">{crossfadeSecs}s</span>
</div>
<input
type="range"
min={1}
max={10}
step={0.5}
value={crossfadeSecs}
onChange={e => {
setCrossfadeSecs(Number(e.target.value));
setCrossfadeEnabled(true);
}}
className="crossfade-popover-slider"
/>
<div className="crossfade-popover-range">
<span>1s</span><span>10s</span>
</div>
</div>
)}
</div>
</div>
{currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from 'lucide-react';
const PsysonicLogo = () => (
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" style={{ borderRadius: '8px' }} />
<img src="/logo-psysonic.png" alt="Psysonic Logo" width="36" height="36" />
);
const navItems = [
+155
View File
@@ -0,0 +1,155 @@
import { Check } from 'lucide-react';
interface ThemeDef {
id: string;
label: string;
bg: string;
card: string;
accent: string;
}
const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{
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: 'Psysonic Themes',
themes: [
{ id: 'classic-winamp', label: 'Classic Winamp', bg: '#2b2b3a', card: '#000000', accent: '#00ff00' },
{ id: 'poison', label: 'Poison', bg: '#1f1f1f', card: '#282828', accent: '#1bd655' },
{ id: 'nucleo', label: 'Nucleo', bg: '#f5e4c3', card: '#dfc08f', accent: '#9e9a92' },
{ id: 'psychowave', label: 'Psychowave', bg: '#161428', card: '#1f1c38', accent: '#a06ae0' },
],
},
];
interface Props {
value: string;
onChange: (id: string) => void;
}
export default function ThemePicker({ value, onChange }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
{THEME_GROUPS.map(({ group, themes }) => (
<div key={group}>
<div style={{
fontSize: '11px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-muted)',
marginBottom: '10px',
}}>
{group}
</div>
<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>
);
}
+4 -8
View File
@@ -59,8 +59,7 @@ function drawWaveform(
ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement);
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa';
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
@@ -97,14 +96,11 @@ function drawWaveform(
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
}
// Pass 3 — played (gradient + glow)
// Pass 3 — played (accent color + glow)
if (progress > 0) {
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
grad.addColorStop(0, colorBlue);
grad.addColorStop(1, colorMauve);
ctx.globalAlpha = 1;
ctx.fillStyle = grad;
ctx.shadowColor = colorMauve;
ctx.fillStyle = colorAccent;
ctx.shadowColor = colorAccent;
ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break;
+1043 -17
View File
File diff suppressed because it is too large Load Diff
+44 -11
View File
@@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom';
import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
@@ -49,6 +50,8 @@ export default function ArtistDetail() {
const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue);
@@ -58,21 +61,33 @@ export default function ArtistDetail() {
useEffect(() => {
if (!id) return;
setLoading(true);
let ownAlbumIds: Set<string>;
setFeaturedAlbums([]);
getArtist(id).then(artistData => {
setArtist(artistData.artist);
setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred);
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
return Promise.all([
getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => []),
search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })),
]);
}).then(([artistInfo, songsData, searchResults]) => {
}).then(([artistInfo, songsData]) => {
if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
setLoading(false);
}).catch(err => {
console.error(err);
setLoading(false);
});
}, [id]);
// "Also Featured On" — loaded in background after main content renders
useEffect(() => {
if (!id || !artist) return;
const ownAlbumIds = new Set(albums.map(a => a.id));
setFeaturedLoading(true);
search(artist.name, { songCount: 500, artistCount: 0, albumCount: 0 })
.catch(() => ({ songs: [], albums: [], artists: [] }))
.then(searchResults => {
const featuredSongs = (searchResults.songs ?? []).filter(
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
);
@@ -96,13 +111,9 @@ export default function ArtistDetail() {
}
});
setFeaturedAlbums([...albumMap.values()]);
setLoading(false);
}).catch(err => {
console.error(err);
setLoading(false);
setFeaturedLoading(false);
});
}, [id]);
}, [artist?.id]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
@@ -213,9 +224,22 @@ export default function ArtistDetail() {
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button>
{lightboxOpen && (
<CoverLightbox
src={buildCoverArtUrl(coverId, 2000)}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="artist-detail-header">
<div className="artist-detail-avatar">
{coverId ? (
<button
className="artist-detail-avatar-btn"
onClick={() => setLightboxOpen(true)}
aria-label={`${artist.name} Bild vergrößern`}
>
<CachedImage
src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
@@ -223,6 +247,7 @@ export default function ArtistDetail() {
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</button>
) : (
<Users size={64} color="var(--text-muted)" />
)}
@@ -389,14 +414,22 @@ export default function ArtistDetail() {
)}
{/* Also Featured On */}
{featuredAlbums.length > 0 && (
{(featuredLoading || featuredAlbums.length > 0) && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.featuredOn')}
</h2>
{featuredLoading ? (
<div className="album-grid-wrap">
{[...Array(4)].map((_, i) => (
<div key={i} style={{ flex: '0 0 clamp(140px, 15vw, 180px)', borderRadius: '8px', background: 'var(--bg-card)', aspectRatio: '1', opacity: 0.5 }} />
))}
</div>
) : (
<div className="album-grid-wrap" style={{ animation: 'fadeIn 0.3s ease' }}>
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)}
</div>
)}
</>
)}
</div>
+2 -1
View File
@@ -45,6 +45,7 @@ export default function Help() {
{ q: t('help.q22'), a: t('help.a22') },
{ q: t('help.q23'), a: t('help.a23') },
{ q: t('help.q24'), a: t('help.a24') },
{ q: t('help.q29'), a: t('help.a29') },
],
},
{
@@ -100,7 +101,7 @@ export default function Help() {
<div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '1.25rem', alignItems: 'start' }}>
{sections.map((section, si) => (
<section key={si} className="settings-section">
<div className="settings-section-header">
+89 -96
View File
@@ -1,15 +1,16 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
} from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmIsConfigured, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm';
import LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { pingWithCredentials } from '../api/subsonic';
@@ -83,9 +84,10 @@ export default function Settings() {
const auth = useAuthStore();
const theme = useThemeStore();
const navigate = useNavigate();
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation();
const [activeTab, setActiveTab] = useState<Tab>('playback');
const [activeTab, setActiveTab] = useState<Tab>((routeState as { tab?: Tab } | null)?.tab ?? 'playback');
const [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState('');
@@ -287,7 +289,12 @@ export default function Settings() {
{/* Crossfade */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.crossfade')}</div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{t('settings.crossfade')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}>
@@ -318,7 +325,12 @@ export default function Settings() {
{/* Gapless */}
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.gapless')}</div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
{t('settings.gapless')}
<span style={{ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 4, background: 'var(--accent)', color: 'var(--ctp-base)', opacity: 0.85, letterSpacing: '0.04em', textTransform: 'uppercase' }}>
{t('settings.experimental')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.gapless')}>
@@ -329,80 +341,6 @@ export default function Settings() {
</div>
</section>
{/* Last.fm */}
<section className="settings-section">
<div className="settings-section-header">
<Music2 size={18} />
<h2>{t('settings.lfmTitle')}</h2>
</div>
<div className="settings-card">
{auth.lastfmSessionKey ? (
/* ── Connected state ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
{lfmUserInfo && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => auth.disconnectLastfm()}
>
{t('settings.lfmDisconnect')}
</button>
</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
<span className="toggle-track" />
</label>
</div>
</div>
) : lfmState === 'waiting' ? (
/* ── Waiting for browser auth — auto-polling ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{t('settings.lfmConnecting')}
</div>
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
{t('common.cancel')}
</button>
</div>
) : (
/* ── Not connected ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('settings.lfmConnectDesc')}
</p>
{lfmState === 'error' && (
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
)}
{lastfmIsConfigured() ? (
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
{t('settings.lfmConnect')}
</button>
) : (
<p style={{ fontSize: 12, color: 'var(--warning)' }}>
VITE_LASTFM_API_KEY / VITE_LASTFM_API_SECRET not set in build environment.
</p>
)}
</div>
)}
</div>
</section>
</>
)}
@@ -530,23 +468,7 @@ export default function Settings() {
<h2>{t('settings.theme')}</h2>
</div>
<div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}>
<CustomSelect
value={theme.theme}
onChange={v => theme.setTheme(v as any)}
options={[
{ value: 'mocha', label: 'Catppuccin Mocha' },
{ value: 'macchiato', label: 'Catppuccin Macchiato' },
{ value: 'frappe', label: 'Catppuccin Frappé' },
{ value: 'latte', label: 'Catppuccin Latte' },
{ value: 'nord', label: 'Nord · Polar Night' },
{ value: 'nord-snowstorm', label: 'Nord · Snowstorm' },
{ value: 'nord-frost', label: 'Nord · Frost' },
{ value: 'nord-aurora', label: 'Nord · Aurora' },
{ value: 'psychowave', label: 'Psychowave' },
]}
/>
</div>
<ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
</div>
</section>
@@ -561,7 +483,9 @@ export default function Settings() {
value={i18n.language}
onChange={v => i18n.changeLanguage(v)}
options={[
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') },
]}
/>
@@ -653,6 +577,75 @@ export default function Settings() {
)}
</section>
{/* Last.fm */}
<section className="settings-section">
<div className="settings-section-header">
<LastfmIcon size={18} />
<h2>{t('settings.lfmTitle')}</h2>
</div>
<div className="settings-card">
{auth.lastfmSessionKey ? (
/* ── Connected state ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem 1rem', borderRadius: '10px', background: 'color-mix(in srgb, var(--accent) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--accent) 20%, transparent)' }}>
<div style={{ flexShrink: 0, color: '#e31c23' }}><LastfmIcon size={20} /></div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>@{auth.lastfmUsername}</div>
{lfmUserInfo && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2, display: 'flex', gap: '0.75rem' }}>
<span>{t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })}</span>
<span>{t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })}</span>
</div>
)}
</div>
<button
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 10px', flexShrink: 0 }}
onClick={() => auth.disconnectLastfm()}
>
{t('settings.lfmDisconnect')}
</button>
</div>
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.scrobbleEnabled')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.scrobbleDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.scrobbleEnabled')}>
<input type="checkbox" checked={auth.scrobblingEnabled} onChange={e => auth.setScrobblingEnabled(e.target.checked)} id="scrobbling-toggle" />
<span className="toggle-track" />
</label>
</div>
</div>
) : lfmState === 'waiting' ? (
/* ── Waiting for browser auth — auto-polling ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', fontSize: 13, color: 'var(--text-secondary)' }}>
<div className="spinner" style={{ width: 16, height: 16, borderWidth: 2 }} />
{t('settings.lfmConnecting')}
</div>
<button className="btn btn-ghost" style={{ alignSelf: 'flex-start', fontSize: 12 }}
onClick={() => { setLfmState('idle'); setLfmPendingToken(null); }}>
{t('common.cancel')}
</button>
</div>
) : (
/* ── Not connected ── */
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{t('settings.lfmConnectDesc')}
</p>
{lfmState === 'error' && (
<p style={{ fontSize: 12, color: 'var(--danger)' }}>{lfmError}</p>
)}
<button className="btn btn-primary" style={{ alignSelf: 'flex-start' }} onClick={startLastfmConnect}>
{t('settings.lfmConnect')}
</button>
</div>
)}
</div>
</section>
{/* Downloads + Tray */}
<section className="settings-section">
<div className="settings-section-header">
+6 -1
View File
@@ -37,6 +37,7 @@ interface AuthState {
isLoggedIn: boolean;
isConnecting: boolean;
connectionError: string | null;
lastfmSessionError: boolean;
// Actions
addServer: (profile: Omit<ServerProfile, 'id'>) => string;
@@ -49,6 +50,7 @@ interface AuthState {
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
connectLastfm: (sessionKey: string, username: string) => void;
disconnectLastfm: () => void;
setLastfmSessionError: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void;
setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void;
@@ -94,6 +96,7 @@ export const useAuthStore = create<AuthState>()(
isLoggedIn: false,
isConnecting: false,
connectionError: null,
lastfmSessionError: false,
addServer: (profile) => {
const id = generateId();
@@ -132,7 +135,9 @@ export const useAuthStore = create<AuthState>()(
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
disconnectLastfm: () =>
set({ lastfmSessionKey: '', lastfmUsername: '' }),
set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
+30 -5
View File
@@ -160,9 +160,9 @@ function handleAudioProgress(current_time: number, duration: number) {
}
}
// Gapless preload: buffer next track when 30s remain
// Pre-buffer / pre-chain next track when 30 s remain.
const { gaplessEnabled } = useAuthStore.getState();
if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) {
if (dur - current_time < 30 && dur - current_time > 0) {
const { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one'
@@ -170,10 +170,33 @@ function handleAudioProgress(current_time: number, duration: number) {
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id);
if (gaplessEnabled) {
// Gapless ON: decode + chain directly into the Sink now, 30 s in
// advance. By the time the track boundary arrives, the next source is
// already live — no IPC round-trip at the gap point.
const authState = useAuthStore.getState();
const replayGainDb = authState.replayGainEnabled
? (authState.replayGainMode === 'album'
? nextTrack.replayGainAlbumDb
: nextTrack.replayGainTrackDb) ?? null
: null;
const replayGainPeak = authState.replayGainEnabled
? (nextTrack.replayGainPeak ?? null)
: null;
invoke('audio_chain_preload', {
url: nextUrl,
volume: store.volume,
durationHint: nextTrack.duration,
replayGainDb,
replayGainPeak,
}).catch(() => {});
} else {
// Gapless OFF: just pre-download bytes so audio_play finds them cached.
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {});
}
}
}
}
function handleAudioEnded() {
const { repeatMode, currentTrack, queue } = usePlayerStore.getState();
@@ -218,16 +241,18 @@ export function initAudioListeners(): () => void {
// Sync Last.fm loved tracks cache on startup.
usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of crossfade settings to Rust audio engine on startup.
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState();
// Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {});
invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {});
// Keep crossfade settings in sync whenever auth store changes.
// Keep audio settings in sync whenever auth store changes.
const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs,
}).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
});
return () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'classic-winamp' | 'poison' | 'nucleo' | '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';
interface ThemeState {
theme: Theme;
+98 -20
View File
@@ -106,7 +106,7 @@
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ctp-lavender);
color: var(--accent);
margin-bottom: var(--space-2);
}
@@ -148,7 +148,8 @@
}
.hero-play-btn:hover {
background: var(--ctp-lavender);
background: var(--accent);
filter: brightness(1.1);
transform: scale(1.02);
box-shadow: var(--shadow-glow);
}
@@ -630,13 +631,74 @@
padding: var(--space-4) 0 var(--space-6);
}
.album-detail-cover {
.album-detail-cover-btn {
padding: 0;
border-radius: var(--radius-lg);
flex-shrink: 0;
cursor: zoom-in;
position: relative;
overflow: hidden;
display: block;
width: clamp(120px, 15vw, 200px);
height: clamp(120px, 15vw, 200px);
}
.album-detail-cover-btn:hover .album-detail-cover {
transform: scale(1.04);
}
.album-detail-cover {
width: 100%;
height: 100%;
border-radius: var(--radius-lg);
object-fit: cover;
box-shadow: var(--shadow-lg);
flex-shrink: 0;
display: block;
transition: transform 0.2s ease;
}
/* ─── Cover Lightbox ─── */
.cover-lightbox-overlay {
position: fixed;
inset: 0;
z-index: 9000;
background: rgba(0, 0, 0, 0.88);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8vh;
cursor: zoom-out;
backdrop-filter: blur(6px);
}
.cover-lightbox-img {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-lg);
box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8);
cursor: default;
}
.cover-lightbox-close {
position: fixed;
top: 24px;
right: 24px;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--transition-fast);
z-index: 1;
}
.cover-lightbox-close:hover {
background: rgba(255, 255, 255, 0.24);
}
.album-cover-placeholder {
@@ -1179,6 +1241,21 @@
border: 1px solid var(--border-subtle);
}
.artist-detail-avatar-btn {
display: block;
width: 100%;
height: 100%;
padding: 0;
border: none;
background: none;
cursor: zoom-in;
transition: transform 0.2s ease, filter 0.2s ease;
}
.artist-detail-avatar-btn:hover {
transform: scale(1.04);
filter: brightness(1.08);
}
.artist-detail-meta {
flex: 1;
min-width: 0;
@@ -1422,7 +1499,7 @@
justify-content: space-between;
gap: var(--space-4);
width: 100%;
padding: var(--space-4) var(--space-5);
padding: 10px var(--space-4);
text-align: left;
font-size: 14px;
font-weight: 500;
@@ -1455,7 +1532,7 @@
}
.help-answer {
padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px);
padding: 10px var(--space-4) var(--space-4) calc(var(--space-4) + 3px);
font-size: 13px;
color: var(--text-secondary);
line-height: 1.65;
@@ -1730,11 +1807,11 @@
}
@keyframes ken-burns {
0% { transform: scale(1.1) translate(-8%, -8%); }
25% { transform: scale(1.15) translate(8%, -8%); }
50% { transform: scale(1.1) translate(8%, 8%); }
75% { transform: scale(1.15) translate(-8%, 8%); }
100% { transform: scale(1.1) translate(-8%, -8%); }
0% { transform: scale(1.05) translate(-4%, -1%); }
25% { transform: scale(1.08) translate( 4%, -1.5%); }
50% { transform: scale(1.06) translate( 3%, 1%); }
75% { transform: scale(1.09) translate(-3%, 1.5%); }
100% { transform: scale(1.05) translate(-4%, -1%); }
}
/* ── Blurred background ── */
@@ -1742,9 +1819,9 @@
position: absolute;
inset: -30%;
background-size: cover;
background-position: center center;
background-position: center 20%;
filter: blur(6px) brightness(0.25) saturate(1.6);
animation: ken-burns 90s ease-in-out infinite;
animation: ken-burns 120s linear infinite;
z-index: 0;
will-change: transform;
pointer-events: none;
@@ -1802,7 +1879,7 @@
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ctp-lavender);
color: var(--accent);
margin: 0;
opacity: 0.9;
}
@@ -1971,7 +2048,7 @@
}
.fs-btn.active {
color: var(--ctp-lavender);
color: var(--accent);
}
.fs-btn-sm {
@@ -1982,15 +2059,16 @@
.fs-btn-play {
width: 54px;
height: 54px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
background: var(--accent);
color: var(--ctp-crust);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
.fs-btn-play:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
background: var(--accent);
color: var(--ctp-crust);
transform: scale(1.07);
filter: brightness(1.12);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
}
@@ -3011,7 +3089,7 @@
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border: 1px solid var(--border-dropdown, var(--border));
border-radius: var(--radius-sm);
color: var(--text-primary);
font-size: 0.875rem;
@@ -3043,9 +3121,9 @@
}
.custom-select-dropdown {
background: var(--bg-card);
border: 1px solid var(--border-subtle);
border: 1px solid var(--border-dropdown, var(--border));
border-radius: var(--radius-md);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
box-shadow: 0 8px 32px var(--shadow-dropdown, rgba(0, 0, 0, 0.4));
overflow-y: auto;
overscroll-behavior: contain;
}
+133 -10
View File
@@ -62,7 +62,7 @@
font-size: 20px;
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue));
background: linear-gradient(135deg, var(--accent), var(--ctp-blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -439,7 +439,7 @@
.player-btn-primary {
width: 46px;
height: 46px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender));
background: var(--accent);
color: var(--ctp-crust);
border-radius: 50%;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
@@ -447,10 +447,11 @@
}
.player-btn-primary:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve));
background: var(--accent);
color: var(--ctp-crust);
transform: scale(1.06);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
filter: brightness(1.12);
}
/* Waveform seekbar section */
@@ -581,6 +582,121 @@
flex-shrink: 0;
}
.queue-action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
color: var(--text-muted);
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.queue-action-btn:hover:not(:disabled) {
background: var(--bg-hover);
color: var(--text-primary);
}
.queue-action-btn:disabled {
opacity: 0.35;
cursor: default;
}
.queue-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 var(--space-3);
height: 44px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-subtle);
background: var(--bg-app);
}
.crossfade-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 170px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 10px 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
z-index: 200;
display: flex;
flex-direction: column;
gap: 8px;
}
.crossfade-popover-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 600;
color: var(--accent);
letter-spacing: 0.03em;
}
.crossfade-popover-value {
margin-left: auto;
font-size: 12px;
font-weight: 700;
font-family: monospace;
color: var(--text-primary);
}
.crossfade-popover-slider {
width: 100%;
accent-color: var(--accent);
cursor: pointer;
}
.crossfade-popover-range {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--text-muted);
}
.queue-toolbar-sep {
width: 1px;
height: 18px;
background: var(--border-subtle);
margin: 0 2px;
flex-shrink: 0;
}
.queue-round-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 50%;
color: var(--text-primary);
background: var(--bg-hover);
border: none;
cursor: pointer;
transition: background var(--transition-fast), color var(--transition-fast);
flex-shrink: 0;
}
.queue-round-btn:hover:not(:disabled) {
background: var(--border);
color: var(--text-primary);
}
.queue-round-btn:disabled {
opacity: 0.35;
cursor: default;
}
.queue-round-btn.active {
color: var(--ctp-base);
background: var(--accent);
}
.queue-current-track {
padding: var(--space-3) var(--space-4);
display: flex;
@@ -602,6 +718,7 @@
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
position: relative;
}
.queue-current-cover img {
@@ -635,15 +752,21 @@
}
.queue-current-tech {
font-size: 10px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 9px;
font-family: monospace;
letter-spacing: 0.05em;
background: var(--bg-surface);
color: var(--text-muted);
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--border-subtle);
display: inline-block;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(4px);
color: rgba(255, 255, 255, 0.85);
padding: 3px 6px;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-divider {
+775 -47
View File
@@ -256,6 +256,8 @@
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--border-dropdown: #576070;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
@@ -364,6 +366,8 @@
--text-muted: #7898b4;
--border: #2d3f52;
--border-subtle: #253545;
--border-dropdown: #3d546e;
--shadow-dropdown: rgba(0, 0, 0, 0.6);
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
@@ -417,64 +421,786 @@
--text-muted: #8894a4;
--border: #434c5e;
--border-subtle: #3b4252;
--border-dropdown: #576070;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #a3be8c;
--warning: #ebcb8b;
--danger: #bf616a;
}
/* ─── Poison ─── */
[data-theme='poison'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%231bd655%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette — charcoal grays + phosphor green ── */
--ctp-crust: #0e0e0e;
--ctp-mantle: #161616;
--ctp-base: #1f1f1f;
--ctp-surface0: #282828;
--ctp-surface1: #323232;
--ctp-surface2: #3e3e3e;
--ctp-overlay0: #525252;
--ctp-overlay1: #686868;
--ctp-overlay2: #848484;
--ctp-text: #d0d0cc;
--ctp-subtext1: #a8a8a4;
--ctp-subtext0: #787874;
/* Phosphor green accent — the Winamp LED display color */
--ctp-mauve: #1bd655;
--ctp-lavender: #28e860;
--ctp-green: #1bd655;
--ctp-teal: #1ab8a8;
--ctp-sky: #58a8e0;
--ctp-blue: #4888d0;
--ctp-sapphire: #3870b8;
--ctp-pink: #c86888;
--ctp-flamingo: #b85858;
--ctp-rosewater: #c88080;
--ctp-yellow: #c8a830;
--ctp-peach: #c07838;
--ctp-maroon: #a03030;
--ctp-red: #c03030;
/* ── Semantic tokens ── */
--bg-app: #1f1f1f;
--bg-sidebar: #161616;
--bg-card: #282828;
--bg-hover: #323232;
--bg-player: #0e0e0e;
--bg-glass: rgba(22, 22, 22, 0.90);
--accent: #1bd655;
--accent-dim: rgba(27, 214, 85, 0.12);
--accent-glow: rgba(27, 214, 85, 0.35);
--text-primary: #d0d0cc;
--text-secondary:#a8a8a4;
--text-muted: #686866;
--border: #3a3a38;
--border-subtle: #282826;
--positive: #1bd655;
--warning: #c8a830;
--danger: #c03030;
}
/* ── Winamp: LED display glow + tactile buttons ── */
[data-theme='poison'] .player-track-name {
text-shadow: 0 0 8px rgba(27, 214, 85, 0.7), 0 0 2px rgba(27, 214, 85, 0.4);
color: #1bd655;
}
[data-theme='poison'] .player-track-artist {
color: #1bd655;
opacity: 0.7;
}
[data-theme='poison'] .btn {
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
[data-theme='poison'] .player-btn,
[data-theme='poison'] .player-btn-primary {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(0, 0, 0, 0.3);
}
/* ─── Classic Winamp — Winamp 2.x Base Skin ─── */
[data-theme='classic-winamp'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8c8d0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette ── */
--ctp-crust: #000000;
--ctp-mantle: #1e1e28;
--ctp-base: #2b2b3a;
--ctp-surface0: #353545;
--ctp-surface1: #404055;
--ctp-surface2: #4e4e65;
--ctp-overlay0: #5a5a75;
--ctp-overlay1: #6e6e88;
--ctp-overlay2: #8a8a96;
--ctp-text: #c8c8d0;
--ctp-subtext1: #a0a0ac;
--ctp-subtext0: #8a8a96;
/* Winamp orange accent + iconic green glow */
--ctp-mauve: #d4cc46;
--ctp-lavender: #e0d860;
--ctp-green: #00ff00;
--ctp-teal: #00cc88;
--ctp-sky: #5ab8e0;
--ctp-blue: #4898d0;
--ctp-sapphire: #3878b8;
--ctp-pink: #c06888;
--ctp-flamingo: #b85858;
--ctp-rosewater: #c88080;
--ctp-yellow: #e8c050;
--ctp-peach: #d08840;
--ctp-maroon: #a03030;
--ctp-red: #c03030;
/* ── Semantic tokens ── */
--bg-app: #2b2b3a;
--bg-sidebar: #1e1e28;
--bg-card: #353545;
--bg-hover: #404055;
--bg-player: #000000;
--bg-glass: rgba(43, 43, 58, 0.90);
--accent: #d4cc46;
--accent-dim: rgba(212, 204, 70, 0.15);
--accent-glow: rgba(212, 204, 70, 0.35);
--volume-accent: #de9b35;
--text-primary: #c8c8d0;
--text-secondary:#a0a0ac;
--text-muted: #6e6e88;
--border: #5a5a75;
--border-subtle: #353545;
--positive: #00cc66;
--warning: #d4cc46;
--danger: #c03030;
}
/* ── Classic Winamp: LCD display + bevel buttons ── */
[data-theme='classic-winamp'] .player-track-name {
color: #00ff00;
text-shadow: 0 0 6px rgba(0, 255, 0, 0.65), 0 0 2px rgba(0, 255, 0, 0.4);
font-family: 'Courier New', 'Lucida Console', monospace;
letter-spacing: 0.04em;
}
[data-theme='classic-winamp'] .player-track-artist {
color: #00cc00;
font-family: 'Courier New', 'Lucida Console', monospace;
opacity: 0.85;
}
[data-theme='classic-winamp'] .btn {
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4);
}
[data-theme='classic-winamp'] .player-btn,
[data-theme='classic-winamp'] .player-btn-primary {
box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 2px 5px rgba(0,0,0,0.5);
}
/* ─── Nucleo — Classic HiFi / Winamp Brass ─── */
[data-theme='nucleo'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238c7356%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette ── */
--ctp-crust: #c8a458;
--ctp-mantle: #dfc08f;
--ctp-base: #e9d3ac;
--ctp-surface0: #eedbb8;
--ctp-surface1: #f5e4c3;
--ctp-surface2: #f9ecd4;
--ctp-overlay0: #b09060;
--ctp-overlay1: #987848;
--ctp-overlay2: #805c30;
--ctp-text: #2c1e10;
--ctp-subtext1: #5a3e20;
--ctp-subtext0: #8c7356;
/* Accents — silver metal + muted brass tones */
--ctp-mauve: #9e9a92; /* silver — primary accent */
--ctp-lavender: #b8b4ac; /* lighter silver */
--ctp-pink: #c06880;
--ctp-flamingo: #c06050;
--ctp-rosewater: #d08070;
--ctp-blue: #5878a8; /* muted steel blue */
--ctp-sapphire: #4868a0;
--ctp-sky: #6898b8;
--ctp-teal: #487878;
--ctp-green: #587838; /* muted olive */
--ctp-yellow: #a88020; /* dark burnished gold */
--ctp-peach: #b87040;
--ctp-maroon: #903030;
--ctp-red: #a03828;
/* ── Semantic tokens ── */
--bg-app: #f5e4c3;
--bg-sidebar: #e9d3ac;
--bg-card: #eedbb8;
--bg-hover: #e3cc9e;
--bg-player: #dfc08f;
--bg-glass: rgba(245, 228, 195, 0.90);
--accent: #9e9a92;
--accent-dim: rgba(158, 154, 146, 0.15);
--accent-glow: rgba(158, 154, 146, 0.35);
--text-primary: #2c1e10;
--text-secondary:#5a3e20;
--text-muted: #8c7356;
--border: #b89a69;
--border-subtle: #dfc08f;
--positive: #587838;
--warning: #a88020;
--danger: #a03828;
}
/* ── Nucleo: skeuomorphic detail layer ── */
[data-theme='nucleo'] .sidebar {
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.06) 0px,
rgba(255,255,255,0.06) 1px,
transparent 1px,
transparent 5px
);
}
[data-theme='nucleo'] .player-bar {
background-image: repeating-linear-gradient(
90deg,
rgba(255,255,255,0.05) 0px,
rgba(255,255,255,0.05) 1px,
transparent 1px,
transparent 5px
);
}
[data-theme='nucleo'] .player-track-name {
text-shadow: 0 0 10px rgba(255, 244, 219, 0.75), 0 0 2px rgba(255, 244, 219, 0.4);
}
[data-theme='nucleo'] .btn {
box-shadow: 0 2px 4px rgba(44, 30, 16, 0.20), inset 0 1px 0 rgba(255, 255, 255, 0.35);
}
[data-theme='nucleo'] .btn-surface {
background: #c8a860;
color: #2c1e10;
}
[data-theme='nucleo'] .btn-surface:hover { background: #b89448; }
[data-theme='nucleo'] .btn-ghost:hover {
background: #c8a860;
color: #2c1e10;
}
[data-theme='nucleo'] .player-btn,
[data-theme='nucleo'] .player-btn-primary {
box-shadow: 0 2px 5px rgba(44, 30, 16, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
/* ─── Psychowave — Synthwave / Retrowave ─── */
[data-theme='psychowave'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c9a8ff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b09ee0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
/* ── Palette ── */
--ctp-crust: #120720;
--ctp-mantle: #160a28;
--ctp-base: #1c0b35;
--ctp-surface0: #2a1050;
--ctp-surface1: #381860;
--ctp-surface2: #4a2878;
--ctp-overlay0: #5e3d90;
--ctp-overlay1: #7b50b8;
--ctp-overlay2: #9868d8;
/* Soft lavender text */
--ctp-text: #f0e6ff;
--ctp-subtext1: #c9a8ff;
--ctp-subtext0: #a882e0;
/* Neon accents */
--ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */
--ctp-lavender: #bf5fff; /* electric purple — button gradient end */
--ctp-pink: #ff2d78;
--ctp-flamingo: #ff6b9d;
--ctp-rosewater: #ff8fc0;
--ctp-blue: #00d4ff; /* electric cyan */
--ctp-sapphire: #007aff;
--ctp-sky: #00bfff;
--ctp-teal: #00e5cc;
--ctp-green: #39ff14; /* neon green */
--ctp-yellow: #ffd700; /* neon gold */
--ctp-peach: #ff8c42;
--ctp-maroon: #ff4d6d;
--ctp-red: #ff2d55;
--ctp-crust: #0d0c1a;
--ctp-mantle: #110f22;
--ctp-base: #161428;
--ctp-surface0: #1f1c38;
--ctp-surface1: #2a2750;
--ctp-surface2: #383468;
--ctp-overlay0: #4a4580;
--ctp-overlay1: #6460a8;
--ctp-overlay2: #8078c8;
/* Text */
--ctp-text: #e6e0f6;
--ctp-subtext1: #b09ee0;
--ctp-subtext0: #8878c0;
/* Accents — muted synthwave palette */
--ctp-mauve: #a06ae0; /* soft violet — primary accent */
--ctp-lavender: #7878d8; /* muted indigo — gradient end */
--ctp-pink: #c060a8; /* dusty rose */
--ctp-flamingo: #c07090;
--ctp-rosewater: #c89090;
--ctp-blue: #58a8e8; /* steel blue */
--ctp-sapphire: #4880c8;
--ctp-sky: #60c0d8;
--ctp-teal: #40a8a8;
--ctp-green: #58c878; /* soft mint green */
--ctp-yellow: #c8a848; /* muted gold */
--ctp-peach: #c87848;
--ctp-maroon: #c05868;
--ctp-red: #b04060;
/* ── Semantic tokens ── */
--bg-app: #1c0b35;
--bg-sidebar: #160a28;
--bg-card: #2a1050;
--bg-hover: #381860;
--bg-player: #120720;
--bg-glass: rgba(28, 11, 53, 0.82);
--accent: #ff2d78;
--accent-dim: rgba(255, 45, 120, 0.15);
--accent-glow: rgba(255, 45, 120, 0.45);
--text-primary: #f0e6ff;
--text-secondary:#c9a8ff;
--text-muted: #9b72d0;
--border: #4a2878;
--border-subtle: #2a1050;
--positive: #39ff14;
--warning: #ffd700;
--danger: #ff2d55;
--bg-app: #161428;
--bg-sidebar: #110f22;
--bg-card: #1f1c38;
--bg-hover: #2a2750;
--bg-player: #0d0c1a;
--bg-glass: rgba(22, 20, 40, 0.85);
--accent: #a06ae0;
--accent-dim: rgba(160, 106, 224, 0.12);
--accent-glow: rgba(160, 106, 224, 0.30);
--text-primary: #e6e0f6;
--text-secondary:#b09ee0;
--text-muted: #706898;
--border: #383468;
--border-subtle: #1f1c38;
--positive: #58c878;
--warning: #c8a848;
--danger: #b04060;
}
/* ─── Gruvbox Dark Hard ─── */
[data-theme='gruvbox-dark-hard'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #1d2021;
--ctp-mantle: #1d2021;
--ctp-base: #282828;
--ctp-surface0: #3c3836;
--ctp-surface1: #504945;
--ctp-surface2: #665c54;
--ctp-overlay0: #7c6f64;
--ctp-overlay1: #928374;
--ctp-overlay2: #a89984;
--ctp-text: #ebdbb2;
--ctp-subtext1: #d5c4a1;
--ctp-subtext0: #bdae93;
--ctp-mauve: #d3869b;
--ctp-lavender: #d3869b;
--ctp-pink: #d3869b;
--ctp-flamingo: #fb4934;
--ctp-rosewater: #fb4934;
--ctp-blue: #83a598;
--ctp-sapphire: #83a598;
--ctp-sky: #8ec07c;
--ctp-teal: #8ec07c;
--ctp-green: #b8bb26;
--ctp-yellow: #fabd2f;
--ctp-peach: #fe8019;
--ctp-maroon: #fb4934;
--ctp-red: #cc241d;
--bg-app: #1d2021;
--bg-sidebar: #141617;
--bg-card: #3c3836;
--bg-hover: #504945;
--bg-player: #1d2021;
--bg-glass: rgba(29, 32, 33, 0.82);
--accent: #fabd2f;
--accent-dim: rgba(250, 189, 47, 0.15);
--accent-glow: rgba(250, 189, 47, 0.3);
--text-primary: #ebdbb2;
--text-secondary:#d5c4a1;
--text-muted: #928374;
--border: #504945;
--border-subtle: #3c3836;
--border-dropdown: #665c54;
--shadow-dropdown: rgba(0, 0, 0, 0.6);
--positive: #b8bb26;
--warning: #fabd2f;
--danger: #fb4934;
}
/* ─── Gruvbox Dark Medium ─── */
[data-theme='gruvbox-dark-medium'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #1d2021;
--ctp-mantle: #1d2021;
--ctp-base: #282828;
--ctp-surface0: #3c3836;
--ctp-surface1: #504945;
--ctp-surface2: #665c54;
--ctp-overlay0: #7c6f64;
--ctp-overlay1: #928374;
--ctp-overlay2: #a89984;
--ctp-text: #ebdbb2;
--ctp-subtext1: #d5c4a1;
--ctp-subtext0: #bdae93;
--ctp-mauve: #d3869b;
--ctp-lavender: #d3869b;
--ctp-pink: #d3869b;
--ctp-flamingo: #fb4934;
--ctp-rosewater: #fb4934;
--ctp-blue: #83a598;
--ctp-sapphire: #83a598;
--ctp-sky: #8ec07c;
--ctp-teal: #8ec07c;
--ctp-green: #b8bb26;
--ctp-yellow: #fabd2f;
--ctp-peach: #fe8019;
--ctp-maroon: #fb4934;
--ctp-red: #cc241d;
--bg-app: #282828;
--bg-sidebar: #1d2021;
--bg-card: #3c3836;
--bg-hover: #504945;
--bg-player: #1d2021;
--bg-glass: rgba(40, 40, 40, 0.82);
--accent: #fabd2f;
--accent-dim: rgba(250, 189, 47, 0.15);
--accent-glow: rgba(250, 189, 47, 0.3);
--text-primary: #ebdbb2;
--text-secondary:#d5c4a1;
--text-muted: #928374;
--border: #504945;
--border-subtle: #3c3836;
--border-dropdown: #665c54;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #b8bb26;
--warning: #fabd2f;
--danger: #fb4934;
}
/* ─── Gruvbox Dark Soft ─── */
[data-theme='gruvbox-dark-soft'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #1d2021;
--ctp-mantle: #282828;
--ctp-base: #32302f;
--ctp-surface0: #3c3836;
--ctp-surface1: #504945;
--ctp-surface2: #665c54;
--ctp-overlay0: #7c6f64;
--ctp-overlay1: #928374;
--ctp-overlay2: #a89984;
--ctp-text: #ebdbb2;
--ctp-subtext1: #d5c4a1;
--ctp-subtext0: #bdae93;
--ctp-mauve: #d3869b;
--ctp-lavender: #d3869b;
--ctp-pink: #d3869b;
--ctp-flamingo: #fb4934;
--ctp-rosewater: #fb4934;
--ctp-blue: #83a598;
--ctp-sapphire: #83a598;
--ctp-sky: #8ec07c;
--ctp-teal: #8ec07c;
--ctp-green: #b8bb26;
--ctp-yellow: #fabd2f;
--ctp-peach: #fe8019;
--ctp-maroon: #fb4934;
--ctp-red: #cc241d;
--bg-app: #32302f;
--bg-sidebar: #282828;
--bg-card: #45403d;
--bg-hover: #504945;
--bg-player: #282828;
--bg-glass: rgba(50, 48, 47, 0.82);
--accent: #fabd2f;
--accent-dim: rgba(250, 189, 47, 0.15);
--accent-glow: rgba(250, 189, 47, 0.3);
--text-primary: #ebdbb2;
--text-secondary:#d5c4a1;
--text-muted: #928374;
--border: #504945;
--border-subtle: #3c3836;
--border-dropdown: #665c54;
--shadow-dropdown: rgba(0, 0, 0, 0.5);
--positive: #b8bb26;
--warning: #fabd2f;
--danger: #fb4934;
}
/* ─── Gruvbox Light Hard ─── */
[data-theme='gruvbox-light-hard'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #ebdbb2;
--ctp-mantle: #f2e5bc;
--ctp-base: #f9f5d7;
--ctp-surface0: #f2e5bc;
--ctp-surface1: #ebdbb2;
--ctp-surface2: #d5c4a1;
--ctp-overlay0: #bdae93;
--ctp-overlay1: #a89984;
--ctp-overlay2: #928374;
--ctp-text: #3c3836;
--ctp-subtext1: #504945;
--ctp-subtext0: #665c54;
--ctp-mauve: #8f3f71;
--ctp-lavender: #8f3f71;
--ctp-pink: #8f3f71;
--ctp-flamingo: #9d0006;
--ctp-rosewater: #cc241d;
--ctp-blue: #076678;
--ctp-sapphire: #076678;
--ctp-sky: #427b58;
--ctp-teal: #427b58;
--ctp-green: #79740e;
--ctp-yellow: #b57614;
--ctp-peach: #af3a03;
--ctp-maroon: #9d0006;
--ctp-red: #cc241d;
--bg-app: #f9f5d7;
--bg-sidebar: #f2e5bc;
--bg-card: #f2e5bc;
--bg-hover: #ebdbb2;
--bg-player: #f2e5bc;
--bg-glass: rgba(249, 245, 215, 0.9);
--accent: #b57614;
--accent-dim: rgba(181, 118, 20, 0.15);
--accent-glow: rgba(181, 118, 20, 0.25);
--text-primary: #3c3836;
--text-secondary:#504945;
--text-muted: #7c6f64;
--border: #d5c4a1;
--border-subtle: #ebdbb2;
--positive: #79740e;
--warning: #b57614;
--danger: #9d0006;
}
/* ─── Gruvbox Light Medium ─── */
[data-theme='gruvbox-light-medium'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #ebdbb2;
--ctp-mantle: #f2e5bc;
--ctp-base: #fbf1c7;
--ctp-surface0: #f2e5bc;
--ctp-surface1: #ebdbb2;
--ctp-surface2: #d5c4a1;
--ctp-overlay0: #bdae93;
--ctp-overlay1: #a89984;
--ctp-overlay2: #928374;
--ctp-text: #3c3836;
--ctp-subtext1: #504945;
--ctp-subtext0: #665c54;
--ctp-mauve: #8f3f71;
--ctp-lavender: #8f3f71;
--ctp-pink: #8f3f71;
--ctp-flamingo: #9d0006;
--ctp-rosewater: #cc241d;
--ctp-blue: #076678;
--ctp-sapphire: #076678;
--ctp-sky: #427b58;
--ctp-teal: #427b58;
--ctp-green: #79740e;
--ctp-yellow: #b57614;
--ctp-peach: #af3a03;
--ctp-maroon: #9d0006;
--ctp-red: #cc241d;
--bg-app: #fbf1c7;
--bg-sidebar: #f2e5bc;
--bg-card: #f2e5bc;
--bg-hover: #ebdbb2;
--bg-player: #f2e5bc;
--bg-glass: rgba(251, 241, 199, 0.9);
--accent: #b57614;
--accent-dim: rgba(181, 118, 20, 0.15);
--accent-glow: rgba(181, 118, 20, 0.25);
--text-primary: #3c3836;
--text-secondary:#504945;
--text-muted: #7c6f64;
--border: #d5c4a1;
--border-subtle: #ebdbb2;
--positive: #79740e;
--warning: #b57614;
--danger: #9d0006;
}
/* ─── Gruvbox Light Soft ─── */
[data-theme='gruvbox-light-soft'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #ebdbb2;
--ctp-mantle: #f2e5bc;
--ctp-base: #f2e5bc;
--ctp-surface0: #ebdbb2;
--ctp-surface1: #d5c4a1;
--ctp-surface2: #bdae93;
--ctp-overlay0: #a89984;
--ctp-overlay1: #928374;
--ctp-overlay2: #7c6f64;
--ctp-text: #3c3836;
--ctp-subtext1: #504945;
--ctp-subtext0: #665c54;
--ctp-mauve: #8f3f71;
--ctp-lavender: #8f3f71;
--ctp-pink: #8f3f71;
--ctp-flamingo: #9d0006;
--ctp-rosewater: #cc241d;
--ctp-blue: #076678;
--ctp-sapphire: #076678;
--ctp-sky: #427b58;
--ctp-teal: #427b58;
--ctp-green: #79740e;
--ctp-yellow: #b57614;
--ctp-peach: #af3a03;
--ctp-maroon: #9d0006;
--ctp-red: #cc241d;
--bg-app: #f2e5bc;
--bg-sidebar: #ebdbb2;
--bg-card: #ebdbb2;
--bg-hover: #d5c4a1;
--bg-player: #ebdbb2;
--bg-glass: rgba(242, 229, 188, 0.9);
--accent: #b57614;
--accent-dim: rgba(181, 118, 20, 0.15);
--accent-glow: rgba(181, 118, 20, 0.25);
--text-primary: #3c3836;
--text-secondary:#504945;
--text-muted: #7c6f64;
--border: #d5c4a1;
--border-subtle: #ebdbb2;
--positive: #79740e;
--warning: #b57614;
--danger: #9d0006;
}
/* ─── Tokyo Night ─── */
[data-theme='tokyo-night'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #16161e;
--ctp-mantle: #1a1b26;
--ctp-base: #1a1b26;
--ctp-surface0: #24283b;
--ctp-surface1: #2f334d;
--ctp-surface2: #3b3f5c;
--ctp-overlay0: #414868;
--ctp-overlay1: #565f89;
--ctp-overlay2: #6272a4;
--ctp-text: #c0caf5;
--ctp-subtext1: #a9b1d6;
--ctp-subtext0: #9aa5ce;
--ctp-mauve: #bb9af7;
--ctp-lavender: #c0caf5;
--ctp-pink: #f7768e;
--ctp-flamingo: #ff9e64;
--ctp-rosewater: #f7768e;
--ctp-blue: #7aa2f7;
--ctp-sapphire: #7dcfff;
--ctp-sky: #7dcfff;
--ctp-teal: #2ac3de;
--ctp-green: #9ece6a;
--ctp-yellow: #e0af68;
--ctp-peach: #ff9e64;
--ctp-maroon: #f7768e;
--ctp-red: #f7768e;
--bg-app: #1a1b26;
--bg-sidebar: #16161e;
--bg-card: #24283b;
--bg-hover: #2f334d;
--bg-player: #16161e;
--bg-glass: rgba(26, 27, 38, 0.82);
--accent: #7aa2f7;
--accent-dim: rgba(122, 162, 247, 0.15);
--accent-glow: rgba(122, 162, 247, 0.3);
--text-primary: #c0caf5;
--text-secondary:#a9b1d6;
--text-muted: #565f89;
--border: #2f334d;
--border-subtle: #24283b;
--border-dropdown: #3d425e;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #9ece6a;
--warning: #e0af68;
--danger: #f7768e;
}
/* ─── Tokyo Night Storm ─── */
[data-theme='tokyo-night-storm'] {
color-scheme: dark;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #1f2335;
--ctp-mantle: #1f2335;
--ctp-base: #24283b;
--ctp-surface0: #2f334d;
--ctp-surface1: #3b3f5c;
--ctp-surface2: #414868;
--ctp-overlay0: #565f89;
--ctp-overlay1: #6272a4;
--ctp-overlay2: #737aa2;
--ctp-text: #c0caf5;
--ctp-subtext1: #a9b1d6;
--ctp-subtext0: #9aa5ce;
--ctp-mauve: #bb9af7;
--ctp-lavender: #c0caf5;
--ctp-pink: #f7768e;
--ctp-flamingo: #ff9e64;
--ctp-rosewater: #f7768e;
--ctp-blue: #7aa2f7;
--ctp-sapphire: #7dcfff;
--ctp-sky: #7dcfff;
--ctp-teal: #2ac3de;
--ctp-green: #9ece6a;
--ctp-yellow: #e0af68;
--ctp-peach: #ff9e64;
--ctp-maroon: #f7768e;
--ctp-red: #f7768e;
--bg-app: #24283b;
--bg-sidebar: #1f2335;
--bg-card: #2f334d;
--bg-hover: #3b3f5c;
--bg-player: #1f2335;
--bg-glass: rgba(36, 40, 59, 0.82);
--accent: #7aa2f7;
--accent-dim: rgba(122, 162, 247, 0.15);
--accent-glow: rgba(122, 162, 247, 0.3);
--text-primary: #c0caf5;
--text-secondary:#a9b1d6;
--text-muted: #565f89;
--border: #3b3f5c;
--border-subtle: #2f334d;
--border-dropdown: #414868;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #9ece6a;
--warning: #e0af68;
--danger: #f7768e;
}
/* ─── Tokyo Night Light ─── */
[data-theme='tokyo-night-light'] {
color-scheme: light;
--select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23343b58%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E");
--ctp-crust: #cbccd1;
--ctp-mantle: #d5d6db;
--ctp-base: #d5d6db;
--ctp-surface0: #e9e9ec;
--ctp-surface1: #c4c5cc;
--ctp-surface2: #b4b5be;
--ctp-overlay0: #a0a1ab;
--ctp-overlay1: #9699a3;
--ctp-overlay2: #8488a0;
--ctp-text: #343b58;
--ctp-subtext1: #565a6e;
--ctp-subtext0: #6b6f85;
--ctp-mauve: #5a4a78;
--ctp-lavender: #34548a;
--ctp-pink: #8f2f5c;
--ctp-flamingo: #965027;
--ctp-rosewater: #8c4351;
--ctp-blue: #34548a;
--ctp-sapphire: #0f4b6e;
--ctp-sky: #0f4b6e;
--ctp-teal: #0f4b6e;
--ctp-green: #485e30;
--ctp-yellow: #8f5e15;
--ctp-peach: #965027;
--ctp-maroon: #8c4351;
--ctp-red: #8c4351;
--bg-app: #d5d6db;
--bg-sidebar: #cbccd1;
--bg-card: #e9e9ec;
--bg-hover: #c4c5cc;
--bg-player: #cbccd1;
--bg-glass: rgba(213, 214, 219, 0.9);
--accent: #34548a;
--accent-dim: rgba(52, 84, 138, 0.15);
--accent-glow: rgba(52, 84, 138, 0.25);
--text-primary: #343b58;
--text-secondary:#565a6e;
--text-muted: #9699a3;
--border: #c4c5cc;
--border-subtle: #d5d6db;
--positive: #485e30;
--warning: #8f5e15;
--danger: #8c4351;
}
/* ─── Global Base Settings ─── */
@@ -525,6 +1251,8 @@
html, body, #root {
height: 100%;
min-width: 1280px;
min-height: 720px;
overflow: hidden;
}
@@ -616,7 +1344,7 @@ body.is-dragging * {
color: var(--ctp-crust);
font-weight: 600;
}
.btn-primary:hover { background: var(--ctp-lavender); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:hover { background: var(--accent); filter: brightness(1.1); transform: translateY(-1px); box-shadow: var(--shadow-glow); }
.btn-primary:active { transform: translateY(0); }
.btn-ghost {