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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.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 ## [1.7.2] - 2026-03-20
### Fixed ### 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. - **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) - **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). - **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 ### 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/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). |
| `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. | | `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. |
| `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. | | `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. |
| `src/store/themeStore.ts` | Theme selection (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-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/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/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/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/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. | | `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 ### 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`. Add a function to `src/api/subsonic.ts` using the `api<T>()` helper. The helper automatically injects auth params and unwraps `subsonic-response`.
### Themes ### Themes
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 | `--volume-accent` overrides the volume slider colour independently of `--accent` (used by Classic Winamp for orange volume, yellow accent elsewhere).
|---|---|---|
| `mocha` | Catppuccin dark | Mauve | | Theme | Group | Style | Accent |
| `macchiato` | Catppuccin medium-dark | Mauve | |---|---|---|---|
| `frappe` | Catppuccin medium | Mauve | | `mocha` | Catppuccin | dark | Mauve |
| `latte` | Catppuccin light | Mauve | | `macchiato` | Catppuccin | medium-dark | Mauve |
| `nord` | Nord Polar Night dark | Frost `#88c0d0` | | `frappe` | Catppuccin | medium | Mauve |
| `nord-snowstorm` | Nord Snow Storm light | Deep-Blue `#5e81ac` | | `latte` | Catppuccin | light | Mauve |
| `nord-frost` | Nord deep ocean blue | Frost `#88c0d0` | | `nord` | Nord | Polar Night dark | Frost `#88c0d0` |
| `nord-aurora` | Nord Polar Night + aurora | Purple `#b48ead` | | `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` |
| `psychowave` | Synthwave/retrowave deep purple | Purple `#c084fc` — WIP | | `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). **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. `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 ### 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. **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. - **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`. - **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`.
- **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. - **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" 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. - **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. - **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. - **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. - **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. - **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. - **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 ## ✨ 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. -**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. - 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time.
- 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required. - 🎵 **Last.fm Integration** *(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. - 💾 **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] Multi-server support
- [x] IndexedDB image caching - [x] IndexedDB image caching
- [x] Random Mix with keyword filter & Super Genre mix - [x] Random Mix with keyword filter & Super Genre mix
- [x] 9 themes: Catppuccin series, Nord series, Psychowave - [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave)
- [x] Internationalization (English + German) - [x] Internationalization (English, German, French, Dutch)
- [x] AUR package (Arch / CachyOS) - [x] AUR package (Arch / CachyOS)
### 🚧 In Progress ### 🚧 In Progress
- [ ] **Psychowave theme** — visual refinement still ongoing
- [ ] **Last.fm integration** — stabilising, moving out of beta - [ ] **Last.fm integration** — stabilising, moving out of beta
- [ ] **Gapless playback** — edge case stabilisation - [ ] **Gapless playback** — edge case stabilisation
- [ ] **Crossfade** — stability improvements - [ ] **Crossfade** — stability improvements
### 📋 Planned ### 📋 Planned
- [ ] FLAC seeking fix - [ ] FLAC seeking fix
- [ ] More themes
- [ ] General UI polish & visual refinement - [ ] General UI polish & visual refinement
- [ ] More languages - [ ] More languages
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.7.2", "version": "1.8.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+1 -1
View File
@@ -1,6 +1,6 @@
# Maintainer: stelle <stelle@psychotoxical.dev> # Maintainer: stelle <stelle@psychotoxical.dev>
pkgname=psysonic pkgname=psysonic
pkgver=1.7.2 pkgver=1.8.0
pkgrel=1 pkgrel=1
pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)"
arch=('x86_64') arch=('x86_64')
+1 -1
View File
@@ -3139,7 +3139,7 @@ dependencies = [
[[package]] [[package]]
name = "psysonic" name = "psysonic"
version = "1.7.0" version = "1.8.0"
dependencies = [ dependencies = [
"biquad", "biquad",
"md5", "md5",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "psysonic" name = "psysonic"
version = "1.7.2" version = "1.8.0"
description = "Psysonic Desktop Music Player" description = "Psysonic Desktop Music Player"
authors = [] authors = []
license = "" license = ""
+619 -162
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 total_duration(&self) -> Option<Duration> { self.inner.total_duration() }
fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { 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 { for band in 0..10 {
let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed)); let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed));
self.current_gains[band] = gain_db; 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 { pub(crate) struct PreloadedTrack {
url: String, url: String,
data: Vec<u8>, 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 struct AudioEngine {
pub stream_handle: Arc<rodio::OutputStreamHandle>, pub stream_handle: Arc<rodio::OutputStreamHandle>,
pub current: Arc<Mutex<AudioCurrent>>, pub current: Arc<Mutex<AudioCurrent>>,
/// Monotonically incremented on each audio_play / audio_stop call. /// Monotonically incremented on each audio_play (non-chain) / 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.
pub generation: Arc<AtomicU64>, pub generation: Arc<AtomicU64>,
pub http_client: reqwest::Client, pub http_client: reqwest::Client,
pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_gains: Arc<[AtomicU32; 10]>,
pub eq_enabled: Arc<AtomicBool>, pub eq_enabled: Arc<AtomicBool>,
pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>, pub preloaded: Arc<Mutex<Option<PreloadedTrack>>>,
pub crossfade_enabled: Arc<AtomicBool>, 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>>>, 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 { pub struct AudioCurrent {
/// The active rodio Sink. `None` when stopped.
pub sink: Option<Sink>, pub sink: Option<Sink>,
pub duration_secs: f64, pub duration_secs: f64,
/// Position (seconds) that we seeked/resumed from.
pub seek_offset: f64, pub seek_offset: f64,
/// Instant when we started counting from seek_offset (None when paused/stopped).
pub play_started: Option<Instant>, pub play_started: Option<Instant>,
/// Set when paused; holds the position at pause time.
pub paused_at: Option<f64>, pub paused_at: Option<f64>,
pub replay_gain_linear: f32, pub replay_gain_linear: f32,
pub base_volume: f32, pub base_volume: f32,
@@ -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<()>) { pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) {
let (tx, rx) = std::sync::mpsc::sync_channel::<rodio::OutputStreamHandle>(0); 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() { .spawn(move || match rodio::OutputStream::try_default() {
Ok((_stream, handle)) => { Ok((_stream, handle)) => {
tx.send(handle).ok(); tx.send(handle).ok();
// Park forever — `_stream` must stay alive for audio to work. loop { std::thread::park(); }
loop {
std::thread::park();
}
}
Err(e) => {
eprintln!("[psysonic] audio output error: {e}");
} }
Err(e) => { eprintln!("[psysonic] audio output error: {e}"); }
}) })
.expect("spawn audio stream thread"); .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_enabled: Arc::new(AtomicBool::new(false)),
crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())), crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())),
fading_out_sink: Arc::new(Mutex::new(None)), fading_out_sink: Arc::new(Mutex::new(None)),
gapless_enabled: Arc::new(AtomicBool::new(false)),
chained_info: Arc::new(Mutex::new(None)),
}; };
(engine, thread) (engine, thread)
@@ -246,11 +496,59 @@ pub struct ProgressPayload {
pub duration: f64, 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 ───────────────────────────────────────────────────────────────── // ─── 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] #[tauri::command]
pub async fn audio_play( pub async fn audio_play(
url: String, url: String,
@@ -261,115 +559,116 @@ pub async fn audio_play(
app: AppHandle, app: AppHandle,
state: State<'_, AudioEngine>, state: State<'_, AudioEngine>,
) -> Result<(), String> { ) -> 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; let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1;
// Stop any previous fading sink unconditionally. // Cancel any pending chain (manual skip while gapless chain was set up).
{ *state.chained_info.lock().unwrap() = None;
let mut fo = state.fading_out_sink.lock().unwrap();
if let Some(old) = fo.take() { // Stop fading-out sink from previous crossfade.
old.stop(); if let Some(old) = state.fading_out_sink.lock().unwrap().take() {
} old.stop();
} }
// NOTE: We intentionally do NOT stop the current Sink here. // Fetch bytes (may use preload cache).
// The old Sink keeps playing while we download + decode the new track. let data = match fetch_data(&url, &state, gen, &app).await? {
// We swap Sinks atomically only after the new data is ready, so the Some(d) => d,
// old track plays to completion (or as close to it as possible). None => return Ok(()), // superseded while downloading
// ── 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()
}
}; };
// Bail if superseded while downloading.
if state.generation.load(Ordering::SeqCst) != gen { if state.generation.load(Ordering::SeqCst) != gen {
return Ok(()); 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_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 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())?; // Measure how much audio Track A actually has left right now.
sink.set_volume(effective_volume); // By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency
let eq_source = EqSource::new( // have consumed ~500800ms from Track A's tail — so its true remaining time
decoder.convert_samples::<f32>(), // 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_gains.clone(),
state.eq_enabled.clone(), state.eq_enabled.clone(),
); done_flag.clone(),
sink.append(eq_source); fade_in_dur,
).map_err(|e| { app.emit("audio:error", &e).ok(); e })?;
// Atomically swap sinks: take old one, install new one. if state.generation.load(Ordering::SeqCst) != gen {
// Capture old volume so the crossfade fade-out starts at the right level. 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 (old_sink, old_vol) = {
let mut cur = state.current.lock().unwrap(); let mut cur = state.current.lock().unwrap();
let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0); let old_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) (old, old_vol)
}; };
// Handle old sink: crossfade fade-out or immediate stop. // Handle old sink: equal-power crossfade or immediate stop.
// The new track is already playing; the old sink runs in parallel during a crossfade.
if crossfade_enabled { if crossfade_enabled {
if let Some(old) = old_sink { 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); *state.fading_out_sink.lock().unwrap() = Some(old);
let fo_arc = state.fading_out_sink.clone(); let fo_arc = state.fading_out_sink.clone();
tokio::spawn(async move { tokio::spawn(async move {
let steps: u32 = 30; // ~100 steps/sec (one step every 10 ms) for smooth equal-power fade.
let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64; // Duration = actual_fade_secs (Track A's measured remaining time),
for i in (0..=steps).rev() { // 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(); let fo = fo_arc.lock().unwrap();
match fo.as_ref() { match fo.as_ref() {
Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)), Some(s) => {
None => return, // cancelled by a subsequent audio_play // Equal-power cos curve: gain_a = cos(t · π/2)
// t goes 1→0 as i goes total_steps→0
let t = i as f32 / total_steps as f32;
let gain = (t * std::f32::consts::FRAC_PI_2).cos();
s.set_volume(old_vol * gain);
true
}
None => false,
} }
} // MutexGuard dropped here before the await
tokio::time::sleep(Duration::from_millis(step_ms)).await; };
if !alive { return; }
tokio::time::sleep(Duration::from_millis(STEP_MS)).await;
} }
if let Some(s) = fo_arc.lock().unwrap().take() { if let Some(s) = fo_arc.lock().unwrap().take() {
s.stop(); s.stop();
@@ -416,24 +725,141 @@ pub async fn audio_play(
app.emit("audio:playing", duration_secs).ok(); app.emit("audio:playing", duration_secs).ok();
// ── Progress + ended detection ──────────────────────────────────────────── // ── Progress + ended detection ────────────────────────────────────────────
// We do NOT use `sink.empty()` because in rodio 0.19 the source moves from spawn_progress_task(
// the pending queue to the active state almost immediately after `append()`, gen,
// making `empty()` return `true` within milliseconds even for long tracks. state.generation.clone(),
// state.current.clone(),
// Instead we use the wall-clock position (seek_offset + elapsed). state.chained_info.clone(),
// `AudioCurrent::position()` is clamped to `duration_secs`, so once it state.crossfade_enabled.clone(),
// reaches the end it stays there. We fire `audio:ended` after two state.crossfade_secs.clone(),
// consecutive ticks where position >= duration - threshold, where threshold done_flag,
// is extended to crossfade_secs when crossfade is enabled so the frontend app,
// 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();
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 { tokio::spawn(async move {
let mut near_end_ticks: u32 = 0; let mut near_end_ticks: u32 = 0;
// Local done-flag reference; swapped on gapless transition.
let mut current_done = initial_done;
loop { loop {
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
@@ -442,20 +868,43 @@ pub async fn audio_play(
break; 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 (pos, dur, is_paused) = {
let cur = current_arc.lock().unwrap(); let cur = current_arc.lock().unwrap();
(cur.position(), cur.duration_secs, cur.paused_at.is_some()) (cur.position(), cur.duration_secs, cur.paused_at.is_some())
}; };
app_clone app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok();
.emit(
"audio:progress",
ProgressPayload { current_time: pos, duration: dur },
)
.ok();
if is_paused { if is_paused {
// Don't advance near-end counter while paused (stay put).
continue; continue;
} }
@@ -467,7 +916,7 @@ pub async fn audio_play(
near_end_ticks += 1; near_end_ticks += 1;
if near_end_ticks >= 2 { if near_end_ticks >= 2 {
gen_counter.fetch_add(1, Ordering::SeqCst); gen_counter.fetch_add(1, Ordering::SeqCst);
app_clone.emit("audio:ended", ()).ok(); app.emit("audio:ended", ()).ok();
break; break;
} }
} else { } else {
@@ -475,8 +924,6 @@ pub async fn audio_play(
} }
} }
}); });
Ok(())
} }
#[tauri::command] #[tauri::command]
@@ -509,6 +956,7 @@ pub fn audio_resume(state: State<'_, AudioEngine>) {
#[tauri::command] #[tauri::command]
pub fn audio_stop(state: State<'_, AudioEngine>) { pub fn audio_stop(state: State<'_, AudioEngine>) {
state.generation.fetch_add(1, Ordering::SeqCst); state.generation.fetch_add(1, Ordering::SeqCst);
*state.chained_info.lock().unwrap() = None;
let mut cur = state.current.lock().unwrap(); let mut cur = state.current.lock().unwrap();
if let Some(sink) = cur.sink.take() { if let Some(sink) = cur.sink.take() {
sink.stop(); sink.stop();
@@ -521,6 +969,15 @@ pub fn audio_stop(state: State<'_, AudioEngine>) {
#[tauri::command] #[tauri::command]
pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> { pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> {
// 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(); let mut cur = state.current.lock().unwrap();
if let Some(sink) = &cur.sink { if let Some(sink) = &cur.sink {
sink.try_seek(Duration::from_secs_f64(seconds.max(0.0))) sink.try_seek(Duration::from_secs_f64(seconds.max(0.0)))
@@ -558,24 +1015,19 @@ pub async fn audio_preload(
duration_hint: f64, duration_hint: f64,
state: State<'_, AudioEngine>, state: State<'_, AudioEngine>,
) -> Result<(), String> { ) -> Result<(), String> {
// Don't re-download if already preloaded for this URL.
{ {
let preloaded = state.preloaded.lock().unwrap(); let preloaded = state.preloaded.lock().unwrap();
if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) { if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) {
return Ok(()); return Ok(());
} }
} }
let response = state let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?;
.http_client
.get(&url)
.send()
.await
.map_err(|e| e.to_string())?;
if !response.status().is_success() { 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(); 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(()) 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_enabled.store(enabled, Ordering::Relaxed);
state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), 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_set_eq,
audio::audio_preload, audio::audio_preload,
audio::audio_set_crossfade, audio::audio_set_crossfade,
audio::audio_set_gapless,
audio::audio_chain_preload,
lastfm_request, lastfm_request,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Psysonic", "productName": "Psysonic",
"version": "1.7.2", "version": "1.8.0",
"identifier": "dev.psysonic.player", "identifier": "dev.psysonic.player",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",
@@ -16,8 +16,8 @@
"title": "Psysonic", "title": "Psysonic",
"width": 1280, "width": 1280,
"height": 800, "height": 800,
"minWidth": 900, "minWidth": 1280,
"minHeight": 600, "minHeight": 720,
"resizable": true, "resizable": true,
"fullscreen": false, "fullscreen": false,
"decorations": true, "decorations": true,
+2
View File
@@ -32,6 +32,7 @@ import ContextMenu from './components/ContextMenu';
import DownloadFolderModal from './components/DownloadFolderModal'; import DownloadFolderModal from './components/DownloadFolderModal';
import TooltipPortal from './components/TooltipPortal'; import TooltipPortal from './components/TooltipPortal';
import ConnectionIndicator from './components/ConnectionIndicator'; import ConnectionIndicator from './components/ConnectionIndicator';
import LastfmIndicator from './components/LastfmIndicator';
import OfflineOverlay from './components/OfflineOverlay'; import OfflineOverlay from './components/OfflineOverlay';
import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useConnectionStatus } from './hooks/useConnectionStatus';
import { useAuthStore } from './store/authStore'; import { useAuthStore } from './store/authStore';
@@ -140,6 +141,7 @@ function AppShell() {
<LiveSearch /> <LiveSearch />
<div className="spacer" /> <div className="spacer" />
<ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} /> <ConnectionIndicator status={connStatus} isLan={isLan} serverName={serverName} />
<LastfmIndicator />
<NowPlayingDropdown /> <NowPlayingDropdown />
<button <button
className="collapse-btn" className="collapse-btn"
+19 -7
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core'; import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from '../store/authStore';
const API_KEY = '9917fb39049225a13bec225ad6d49054'; const API_KEY = '9917fb39049225a13bec225ad6d49054';
const API_SECRET = '03817dda02bee87a178aab7581abae3b'; 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> { async function call(params: Record<string, string>, sign = false, get = false): Promise<any> {
const entries = Object.entries(params) as [string, string][]; const entries = Object.entries(params) as [string, string][];
return invoke('lastfm_request', { try {
params: entries, const result = await invoke('lastfm_request', {
sign, params: entries,
get, sign,
apiKey: API_KEY, get,
apiSecret: API_SECRET, 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> { export async function lastfmGetToken(): Promise<string> {
+20 -3
View File
@@ -1,8 +1,9 @@
import React from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Play, Star, ExternalLink, X, ChevronLeft, Download, ListPlus, Info } from 'lucide-react'; 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 CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
@@ -49,6 +50,7 @@ function BioModal({ bio, onClose }: { bio: string; onClose: () => void }) {
); );
} }
interface AlbumInfo { interface AlbumInfo {
id: string; id: string;
name: string; name: string;
@@ -97,6 +99,7 @@ export default function AlbumHeader({
}: AlbumHeaderProps) { }: AlbumHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const [lightboxOpen, setLightboxOpen] = useState(false);
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
@@ -104,6 +107,13 @@ export default function AlbumHeader({
return ( return (
<> <>
{bioOpen && bio && <BioModal bio={bio} onClose={onCloseBio} />} {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"> <div className="album-detail-header">
{resolvedCoverUrl && ( {resolvedCoverUrl && (
@@ -121,7 +131,14 @@ export default function AlbumHeader({
</button> </button>
<div className="album-detail-hero"> <div className="album-detail-hero">
{coverUrl ? ( {coverUrl ? (
<CachedImage className="album-detail-cover" src={coverUrl} cacheKey={coverKey} alt={`${info.name} Cover`} /> <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> <div className="album-detail-cover album-cover-placeholder"></div>
)} )}
+12 -4
View File
@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { ConnectionStatus } from '../hooks/useConnectionStatus'; import { ConnectionStatus } from '../hooks/useConnectionStatus';
interface Props { interface Props {
@@ -9,17 +10,24 @@ interface Props {
export default function ConnectionIndicator({ status, isLan, serverName }: Props) { export default function ConnectionIndicator({ status, isLan, serverName }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const label = isLan ? 'LAN' : t('connection.extern'); const label = isLan ? 'LAN' : t('connection.extern');
const title = const tooltip =
status === 'connected' status === 'connected'
? t('connection.connected') ? t('connection.connectedTo', { server: serverName })
: status === 'disconnected' : status === 'disconnected'
? t('connection.disconnected') ? t('connection.disconnectedFrom', { server: serverName })
: t('connection.checking'); : t('connection.checking');
return ( 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-led connection-led--${status}`} />
<div className="connection-meta"> <div className="connection-meta">
<span className="connection-type">{label}</span> <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 isPlaying = usePlayerStore(s => s.isPlaying);
const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? ''); const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? '');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [spinning, setSpinning] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const fetchNowPlaying = async () => { 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 // Poll in background so the badge stays current without opening the dropdown
useEffect(() => { useEffect(() => {
fetchNowPlaying(); 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' }}> <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> <h3 style={{ margin: 0, fontSize: '14px', fontWeight: 600 }}>{t('nowPlaying.title')}</h3>
<button <button
onClick={fetchNowPlaying} onClick={handleRefresh}
className={`btn btn-ghost ${loading ? 'animate-spin' : ''}`} className="btn btn-ghost"
style={{ width: '28px', height: '28px', padding: 0 }} style={{ width: '28px', height: '28px', padding: 0, justifyContent: 'center' }}
> >
<RefreshCw size={14} /> <RefreshCw size={14} className={spinning ? 'animate-spin' : ''} />
</button> </button>
</div> </div>
+1 -1
View File
@@ -40,7 +40,7 @@ export default function PlayerBar() {
}, [setVolume]); }, [setVolume]);
const volumeStyle = { 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 ( return (
+105 -34
View File
@@ -1,10 +1,11 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { Track, usePlayerStore } from '../store/playerStore'; import { Track, usePlayerStore } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } 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 { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
function formatTime(seconds: number): string { function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00'; if (!seconds || isNaN(seconds)) return '0:00';
@@ -130,6 +131,30 @@ export default function QueuePanel() {
const enqueue = usePlayerStore(s => s.enqueue); const enqueue = usePlayerStore(s => s.enqueue);
const contextMenu = usePlayerStore(s => s.contextMenu); 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 [draggedIdx, setDraggedIdx] = useState<number | null>(null);
const [dragOverIdx, setDragOverIdx] = useState<number | null>(null); const [dragOverIdx, setDragOverIdx] = useState<number | null>(null);
const isDraggingInternalRef = useRef(false); const isDraggingInternalRef = useRef(false);
@@ -255,8 +280,8 @@ export default function QueuePanel() {
}} }}
> >
<div className="queue-header"> <div className="queue-header">
<div> <div style={{ display: 'flex', alignItems: 'baseline', gap: '8px', minWidth: 0 }}>
<h2 style={{ fontSize: '14px', fontWeight: 600, margin: 0 }}>{t('queue.title')}</h2> <h2 style={{ fontSize: '16px', fontWeight: 700, margin: 0, flexShrink: 0 }}>{t('queue.title')}</h2>
{queue.length > 0 && (() => { {queue.length > 0 && (() => {
const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0); const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0);
const h = Math.floor(totalSecs / 3600); 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')}` ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
: `${m}:${s.toString().padStart(2, '0')}`; : `${m}:${s.toString().padStart(2, '0')}`;
return ( 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} {queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur}
</div> </span>
); );
})()} })()}
</div> </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> </div>
{currentTrack && ( {currentTrack && (
@@ -301,6 +307,13 @@ export default function QueuePanel() {
) : ( ) : (
<div className="fallback"><Music size={32} /></div> <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>
<div className="queue-current-info"> <div className="queue-current-info">
<h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3> <h3 className="truncate" data-tooltip={currentTrack.title}>{currentTrack.title}</h3>
@@ -319,20 +332,78 @@ export default function QueuePanel() {
{currentTrack.year && ( {currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div> <div className="queue-current-sub">{currentTrack.year}</div>
)} )}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '6px' }}> {renderStars(currentTrack.userRating)}
<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>
</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>} {currentTrack && queue.length > 0 && <div className="queue-divider"><span style={{ fontSize: '12px', fontWeight: 600, color: 'var(--text-muted)' }}>{t('queue.nextTracks')}</span></div>}
<div className="queue-list" ref={queueListRef}> <div className="queue-list" ref={queueListRef}>
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
const PsysonicLogo = () => ( 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 = [ 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); ctx.clearRect(0, 0, w, h);
const style = getComputedStyle(document.documentElement); const style = getComputedStyle(document.documentElement);
const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa'; const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7';
const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7';
const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086'; const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086';
const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244'; const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244';
@@ -97,14 +96,11 @@ function drawWaveform(
ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH);
} }
// Pass 3 — played (gradient + glow) // Pass 3 — played (accent color + glow)
if (progress > 0) { if (progress > 0) {
const grad = ctx.createLinearGradient(0, 0, progress * w, 0);
grad.addColorStop(0, colorBlue);
grad.addColorStop(1, colorMauve);
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.fillStyle = grad; ctx.fillStyle = colorAccent;
ctx.shadowColor = colorMauve; ctx.shadowColor = colorAccent;
ctx.shadowBlur = 5; ctx.shadowBlur = 5;
for (let i = 0; i < BAR_COUNT; i++) { for (let i = 0; i < BAR_COUNT; i++) {
if (i / BAR_COUNT >= progress) break; if (i / BAR_COUNT >= progress) break;
+1043 -17
View File
File diff suppressed because it is too large Load Diff
+73 -40
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 { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic';
import AlbumCard from '../components/AlbumCard'; import AlbumCard from '../components/AlbumCard';
import CachedImage from '../components/CachedImage'; import CachedImage from '../components/CachedImage';
import CoverLightbox from '../components/CoverLightbox';
import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell'; import { open } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
@@ -49,6 +50,8 @@ export default function ArtistDetail() {
const [openedLink, setOpenedLink] = useState<string | null>(null); const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]); const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false); const [similarLoading, setSimilarLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const playTrack = usePlayerStore(state => state.playTrack); const playTrack = usePlayerStore(state => state.playTrack);
const enqueue = usePlayerStore(state => state.enqueue); const enqueue = usePlayerStore(state => state.enqueue);
@@ -58,45 +61,18 @@ export default function ArtistDetail() {
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
setLoading(true); setLoading(true);
let ownAlbumIds: Set<string>; setFeaturedAlbums([]);
getArtist(id).then(artistData => { getArtist(id).then(artistData => {
setArtist(artistData.artist); setArtist(artistData.artist);
setAlbums(artistData.albums); setAlbums(artistData.albums);
setIsStarred(!!artistData.artist.starred); setIsStarred(!!artistData.artist.starred);
ownAlbumIds = new Set(artistData.albums.map(a => a.id));
return Promise.all([ return Promise.all([
getArtistInfo(id).catch(() => null), getArtistInfo(id).catch(() => null),
getTopSongs(artistData.artist.name).catch(() => []), 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 (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null);
if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]); if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]);
const featuredSongs = (searchResults.songs ?? []).filter(
song => song.artistId === id && !ownAlbumIds.has(song.albumId)
);
const albumMap = new Map<string, SubsonicAlbum>();
featuredSongs.forEach(song => {
if (!albumMap.has(song.albumId)) {
albumMap.set(song.albumId, {
id: song.albumId,
name: song.album,
artist: song.albumArtist ?? '',
artistId: '',
coverArt: song.coverArt,
songCount: 1,
duration: song.duration,
year: song.year,
});
} else {
const a = albumMap.get(song.albumId)!;
a.songCount++;
a.duration += song.duration;
}
});
setFeaturedAlbums([...albumMap.values()]);
setLoading(false); setLoading(false);
}).catch(err => { }).catch(err => {
console.error(err); console.error(err);
@@ -104,6 +80,41 @@ export default function ArtistDetail() {
}); });
}, [id]); }, [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)
);
const albumMap = new Map<string, SubsonicAlbum>();
featuredSongs.forEach(song => {
if (!albumMap.has(song.albumId)) {
albumMap.set(song.albumId, {
id: song.albumId,
name: song.album,
artist: song.albumArtist ?? '',
artistId: '',
coverArt: song.coverArt,
songCount: 1,
duration: song.duration,
year: song.year,
});
} else {
const a = albumMap.get(song.albumId)!;
a.songCount++;
a.duration += song.duration;
}
});
setFeaturedAlbums([...albumMap.values()]);
setFeaturedLoading(false);
});
}, [artist?.id]);
useEffect(() => { useEffect(() => {
if (!artist || !lastfmIsConfigured()) return; if (!artist || !lastfmIsConfigured()) return;
setSimilarArtists([]); setSimilarArtists([]);
@@ -213,16 +224,30 @@ export default function ArtistDetail() {
<ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span> <ArrowLeft size={16} /> <span>{t('artistDetail.back')}</span>
</button> </button>
{lightboxOpen && (
<CoverLightbox
src={buildCoverArtUrl(coverId, 2000)}
alt={artist.name}
onClose={() => setLightboxOpen(false)}
/>
)}
<div className="artist-detail-header"> <div className="artist-detail-header">
<div className="artist-detail-avatar"> <div className="artist-detail-avatar">
{coverId ? ( {coverId ? (
<CachedImage <button
src={buildCoverArtUrl(coverId, 300)} className="artist-detail-avatar-btn"
cacheKey={coverArtCacheKey(coverId, 300)} onClick={() => setLightboxOpen(true)}
alt={artist.name} aria-label={`${artist.name} Bild vergrößern`}
style={{ width: '100%', height: '100%', objectFit: 'cover' }} >
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} <CachedImage
/> src={buildCoverArtUrl(coverId, 300)}
cacheKey={coverArtCacheKey(coverId, 300)}
alt={artist.name}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
onError={e => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
/>
</button>
) : ( ) : (
<Users size={64} color="var(--text-muted)" /> <Users size={64} color="var(--text-muted)" />
)} )}
@@ -389,14 +414,22 @@ export default function ArtistDetail() {
)} )}
{/* Also Featured On */} {/* Also Featured On */}
{featuredAlbums.length > 0 && ( {(featuredLoading || featuredAlbums.length > 0) && (
<> <>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}> <h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.featuredOn')} {t('artistDetail.featuredOn')}
</h2> </h2>
<div className="album-grid-wrap"> {featuredLoading ? (
{featuredAlbums.map(a => <AlbumCard key={a.id} album={a} />)} <div className="album-grid-wrap">
</div> {[...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> </div>
+2 -1
View File
@@ -45,6 +45,7 @@ export default function Help() {
{ q: t('help.q22'), a: t('help.a22') }, { q: t('help.q22'), a: t('help.a22') },
{ q: t('help.q23'), a: t('help.a23') }, { q: t('help.q23'), a: t('help.a23') },
{ q: t('help.q24'), a: t('help.a24') }, { 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"> <div className="content-body animate-fade-in">
<h1 className="page-title" style={{ marginBottom: '2rem' }}>{t('help.title')}</h1> <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) => ( {sections.map((section, si) => (
<section key={si} className="settings-section"> <section key={si} className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
+89 -96
View File
@@ -1,15 +1,16 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react'; import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { version as appVersion } from '../../package.json'; import { version as appVersion } from '../../package.json';
import changelogRaw from '../../CHANGELOG.md?raw'; import changelogRaw from '../../CHANGELOG.md?raw';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play
} from 'lucide-react'; } from 'lucide-react';
import { open as openUrl } from '@tauri-apps/plugin-shell'; 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 LastfmIcon from '../components/LastfmIcon';
import CustomSelect from '../components/CustomSelect'; import CustomSelect from '../components/CustomSelect';
import ThemePicker from '../components/ThemePicker';
import { useAuthStore, ServerProfile } from '../store/authStore'; import { useAuthStore, ServerProfile } from '../store/authStore';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
import { pingWithCredentials } from '../api/subsonic'; import { pingWithCredentials } from '../api/subsonic';
@@ -83,9 +84,10 @@ export default function Settings() {
const auth = useAuthStore(); const auth = useAuthStore();
const theme = useThemeStore(); const theme = useThemeStore();
const navigate = useNavigate(); const navigate = useNavigate();
const { state: routeState } = useLocation();
const { t, i18n } = useTranslation(); 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 [connStatus, setConnStatus] = useState<Record<string, 'idle' | 'testing' | 'ok' | 'error'>>({});
const [showAddForm, setShowAddForm] = useState(false); const [showAddForm, setShowAddForm] = useState(false);
const [newGenre, setNewGenre] = useState(''); const [newGenre, setNewGenre] = useState('');
@@ -287,7 +289,12 @@ export default function Settings() {
{/* Crossfade */} {/* Crossfade */}
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <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 style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.crossfadeDesc')}</div>
</div> </div>
<label className="toggle-switch" aria-label={t('settings.crossfade')}> <label className="toggle-switch" aria-label={t('settings.crossfade')}>
@@ -318,7 +325,12 @@ export default function Settings() {
{/* Gapless */} {/* Gapless */}
<div className="settings-toggle-row"> <div className="settings-toggle-row">
<div> <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 style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.gaplessDesc')}</div>
</div> </div>
<label className="toggle-switch" aria-label={t('settings.gapless')}> <label className="toggle-switch" aria-label={t('settings.gapless')}>
@@ -329,80 +341,6 @@ export default function Settings() {
</div> </div>
</section> </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> <h2>{t('settings.theme')}</h2>
</div> </div>
<div className="settings-card"> <div className="settings-card">
<div className="form-group" style={{ maxWidth: '300px' }}> <ThemePicker value={theme.theme} onChange={v => theme.setTheme(v as any)} />
<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>
</div> </div>
</section> </section>
@@ -561,7 +483,9 @@ export default function Settings() {
value={i18n.language} value={i18n.language}
onChange={v => i18n.changeLanguage(v)} onChange={v => i18n.changeLanguage(v)}
options={[ options={[
{ value: 'nl', label: t('settings.languageNl') },
{ value: 'en', label: t('settings.languageEn') }, { value: 'en', label: t('settings.languageEn') },
{ value: 'fr', label: t('settings.languageFr') },
{ value: 'de', label: t('settings.languageDe') }, { value: 'de', label: t('settings.languageDe') },
]} ]}
/> />
@@ -653,6 +577,75 @@ export default function Settings() {
)} )}
</section> </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 */} {/* Downloads + Tray */}
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
+6 -1
View File
@@ -37,6 +37,7 @@ interface AuthState {
isLoggedIn: boolean; isLoggedIn: boolean;
isConnecting: boolean; isConnecting: boolean;
connectionError: string | null; connectionError: string | null;
lastfmSessionError: boolean;
// Actions // Actions
addServer: (profile: Omit<ServerProfile, 'id'>) => string; addServer: (profile: Omit<ServerProfile, 'id'>) => string;
@@ -49,6 +50,7 @@ interface AuthState {
setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void; setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void;
connectLastfm: (sessionKey: string, username: string) => void; connectLastfm: (sessionKey: string, username: string) => void;
disconnectLastfm: () => void; disconnectLastfm: () => void;
setLastfmSessionError: (v: boolean) => void;
setMinimizeToTray: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void;
setScrobblingEnabled: (v: boolean) => void; setScrobblingEnabled: (v: boolean) => void;
setMaxCacheMb: (v: number) => void; setMaxCacheMb: (v: number) => void;
@@ -94,6 +96,7 @@ export const useAuthStore = create<AuthState>()(
isLoggedIn: false, isLoggedIn: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
lastfmSessionError: false,
addServer: (profile) => { addServer: (profile) => {
const id = generateId(); const id = generateId();
@@ -132,7 +135,9 @@ export const useAuthStore = create<AuthState>()(
set({ lastfmSessionKey: sessionKey, lastfmUsername: username }), set({ lastfmSessionKey: sessionKey, lastfmUsername: username }),
disconnectLastfm: () => disconnectLastfm: () =>
set({ lastfmSessionKey: '', lastfmUsername: '' }), set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }),
setLastfmSessionError: (v) => set({ lastfmSessionError: v }),
setMinimizeToTray: (v) => set({ minimizeToTray: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }), setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }),
+31 -6
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(); 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 { queue, queueIndex, repeatMode } = store;
const nextIdx = queueIndex + 1; const nextIdx = queueIndex + 1;
const nextTrack = repeatMode === 'one' const nextTrack = repeatMode === 'one'
@@ -170,7 +170,30 @@ function handleAudioProgress(current_time: number, duration: number) {
: (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null));
if (nextTrack && nextTrack.id !== track.id) { if (nextTrack && nextTrack.id !== track.id) {
const nextUrl = buildStreamUrl(nextTrack.id); const nextUrl = buildStreamUrl(nextTrack.id);
invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); 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(() => {});
}
} }
} }
} }
@@ -218,16 +241,18 @@ export function initAudioListeners(): () => void {
// Sync Last.fm loved tracks cache on startup. // Sync Last.fm loved tracks cache on startup.
usePlayerStore.getState().syncLastfmLovedTracks(); usePlayerStore.getState().syncLastfmLovedTracks();
// Initial sync of crossfade settings to Rust audio engine on startup. // Initial sync of audio settings to Rust engine on startup.
const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState(); const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState();
invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); 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) => { const unsubAuth = useAuthStore.subscribe((state) => {
invoke('audio_set_crossfade', { invoke('audio_set_crossfade', {
enabled: state.crossfadeEnabled, enabled: state.crossfadeEnabled,
secs: state.crossfadeSecs, secs: state.crossfadeSecs,
}).catch(() => {}); }).catch(() => {});
invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {});
}); });
return () => { return () => {
+1 -1
View File
@@ -1,7 +1,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist } from 'zustand/middleware'; import { persist } from 'zustand/middleware';
type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave'; 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 { interface ThemeState {
theme: Theme; theme: Theme;
+98 -20
View File
@@ -106,7 +106,7 @@
font-weight: 600; font-weight: 600;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ctp-lavender); color: var(--accent);
margin-bottom: var(--space-2); margin-bottom: var(--space-2);
} }
@@ -148,7 +148,8 @@
} }
.hero-play-btn:hover { .hero-play-btn:hover {
background: var(--ctp-lavender); background: var(--accent);
filter: brightness(1.1);
transform: scale(1.02); transform: scale(1.02);
box-shadow: var(--shadow-glow); box-shadow: var(--shadow-glow);
} }
@@ -630,13 +631,74 @@
padding: var(--space-4) 0 var(--space-6); 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); width: clamp(120px, 15vw, 200px);
height: 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); border-radius: var(--radius-lg);
object-fit: cover; object-fit: cover;
box-shadow: var(--shadow-lg); 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 { .album-cover-placeholder {
@@ -1179,6 +1241,21 @@
border: 1px solid var(--border-subtle); 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 { .artist-detail-meta {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@@ -1422,7 +1499,7 @@
justify-content: space-between; justify-content: space-between;
gap: var(--space-4); gap: var(--space-4);
width: 100%; width: 100%;
padding: var(--space-4) var(--space-5); padding: 10px var(--space-4);
text-align: left; text-align: left;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
@@ -1455,7 +1532,7 @@
} }
.help-answer { .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; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
line-height: 1.65; line-height: 1.65;
@@ -1730,11 +1807,11 @@
} }
@keyframes ken-burns { @keyframes ken-burns {
0% { transform: scale(1.1) translate(-8%, -8%); } 0% { transform: scale(1.05) translate(-4%, -1%); }
25% { transform: scale(1.15) translate(8%, -8%); } 25% { transform: scale(1.08) translate( 4%, -1.5%); }
50% { transform: scale(1.1) translate(8%, 8%); } 50% { transform: scale(1.06) translate( 3%, 1%); }
75% { transform: scale(1.15) translate(-8%, 8%); } 75% { transform: scale(1.09) translate(-3%, 1.5%); }
100% { transform: scale(1.1) translate(-8%, -8%); } 100% { transform: scale(1.05) translate(-4%, -1%); }
} }
/* ── Blurred background ── */ /* ── Blurred background ── */
@@ -1742,9 +1819,9 @@
position: absolute; position: absolute;
inset: -30%; inset: -30%;
background-size: cover; background-size: cover;
background-position: center center; background-position: center 20%;
filter: blur(6px) brightness(0.25) saturate(1.6); 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; z-index: 0;
will-change: transform; will-change: transform;
pointer-events: none; pointer-events: none;
@@ -1802,7 +1879,7 @@
font-weight: 700; font-weight: 700;
letter-spacing: 0.14em; letter-spacing: 0.14em;
text-transform: uppercase; text-transform: uppercase;
color: var(--ctp-lavender); color: var(--accent);
margin: 0; margin: 0;
opacity: 0.9; opacity: 0.9;
} }
@@ -1971,7 +2048,7 @@
} }
.fs-btn.active { .fs-btn.active {
color: var(--ctp-lavender); color: var(--accent);
} }
.fs-btn-sm { .fs-btn-sm {
@@ -1982,15 +2059,16 @@
.fs-btn-play { .fs-btn-play {
width: 54px; width: 54px;
height: 54px; height: 54px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); background: var(--accent);
color: var(--ctp-crust); color: var(--ctp-crust);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
} }
.fs-btn-play:hover { .fs-btn-play:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); background: var(--accent);
color: var(--ctp-crust); color: var(--ctp-crust);
transform: scale(1.07); transform: scale(1.07);
filter: brightness(1.12);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6);
} }
@@ -3011,7 +3089,7 @@
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--bg-input); background: var(--bg-input);
border: 1px solid var(--border); border: 1px solid var(--border-dropdown, var(--border));
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-size: 0.875rem; font-size: 0.875rem;
@@ -3043,9 +3121,9 @@
} }
.custom-select-dropdown { .custom-select-dropdown {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border-subtle); border: 1px solid var(--border-dropdown, var(--border));
border-radius: var(--radius-md); 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; overflow-y: auto;
overscroll-behavior: contain; overscroll-behavior: contain;
} }
+133 -10
View File
@@ -62,7 +62,7 @@
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; 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-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
@@ -439,7 +439,7 @@
.player-btn-primary { .player-btn-primary {
width: 46px; width: 46px;
height: 46px; height: 46px;
background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); background: var(--accent);
color: var(--ctp-crust); color: var(--ctp-crust);
border-radius: 50%; border-radius: 50%;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4);
@@ -447,10 +447,11 @@
} }
.player-btn-primary:hover { .player-btn-primary:hover {
background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); background: var(--accent);
color: var(--ctp-crust); color: var(--ctp-crust);
transform: scale(1.06); transform: scale(1.06);
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow);
filter: brightness(1.12);
} }
/* Waveform seekbar section */ /* Waveform seekbar section */
@@ -581,6 +582,121 @@
flex-shrink: 0; 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 { .queue-current-track {
padding: var(--space-3) var(--space-4); padding: var(--space-3) var(--space-4);
display: flex; display: flex;
@@ -602,6 +718,7 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.2); box-shadow: 0 2px 8px rgba(0,0,0,0.2);
position: relative;
} }
.queue-current-cover img { .queue-current-cover img {
@@ -635,15 +752,21 @@
} }
.queue-current-tech { .queue-current-tech {
font-size: 10px; position: absolute;
bottom: 0;
left: 0;
right: 0;
font-size: 9px;
font-family: monospace; font-family: monospace;
letter-spacing: 0.05em; letter-spacing: 0.05em;
background: var(--bg-surface); background: rgba(0, 0, 0, 0.62);
color: var(--text-muted); backdrop-filter: blur(4px);
padding: 2px 6px; color: rgba(255, 255, 255, 0.85);
border-radius: 4px; padding: 3px 6px;
border: 1px solid var(--border-subtle); text-align: center;
display: inline-block; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.queue-divider { .queue-divider {
+784 -56
View File
@@ -253,9 +253,11 @@
--accent-glow: rgba(136, 192, 208, 0.3); --accent-glow: rgba(136, 192, 208, 0.3);
--text-primary: #eceff4; --text-primary: #eceff4;
--text-secondary:#e5e9f0; --text-secondary:#e5e9f0;
--text-muted: #8894a4; --text-muted: #8894a4;
--border: #434c5e; --border: #434c5e;
--border-subtle: #3b4252; --border-subtle: #3b4252;
--border-dropdown: #576070;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #a3be8c; --positive: #a3be8c;
--warning: #ebcb8b; --warning: #ebcb8b;
--danger: #bf616a; --danger: #bf616a;
@@ -361,9 +363,11 @@
--accent-glow: rgba(136, 192, 208, 0.35); --accent-glow: rgba(136, 192, 208, 0.35);
--text-primary: #eceff4; --text-primary: #eceff4;
--text-secondary:#e5e9f0; --text-secondary:#e5e9f0;
--text-muted: #7898b4; --text-muted: #7898b4;
--border: #2d3f52; --border: #2d3f52;
--border-subtle: #253545; --border-subtle: #253545;
--border-dropdown: #3d546e;
--shadow-dropdown: rgba(0, 0, 0, 0.6);
--positive: #a3be8c; --positive: #a3be8c;
--warning: #ebcb8b; --warning: #ebcb8b;
--danger: #bf616a; --danger: #bf616a;
@@ -414,67 +418,789 @@
--accent-glow: rgba(180, 142, 173, 0.3); --accent-glow: rgba(180, 142, 173, 0.3);
--text-primary: #eceff4; --text-primary: #eceff4;
--text-secondary:#e5e9f0; --text-secondary:#e5e9f0;
--text-muted: #8894a4; --text-muted: #8894a4;
--border: #434c5e; --border: #434c5e;
--border-subtle: #3b4252; --border-subtle: #3b4252;
--border-dropdown: #576070;
--shadow-dropdown: rgba(0, 0, 0, 0.55);
--positive: #a3be8c; --positive: #a3be8c;
--warning: #ebcb8b; --warning: #ebcb8b;
--danger: #bf616a; --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 ─── */ /* ─── Psychowave — Synthwave / Retrowave ─── */
[data-theme='psychowave'] { [data-theme='psychowave'] {
color-scheme: dark; 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 ── */ /* ── Palette ── */
--ctp-crust: #120720; --ctp-crust: #0d0c1a;
--ctp-mantle: #160a28; --ctp-mantle: #110f22;
--ctp-base: #1c0b35; --ctp-base: #161428;
--ctp-surface0: #2a1050; --ctp-surface0: #1f1c38;
--ctp-surface1: #381860; --ctp-surface1: #2a2750;
--ctp-surface2: #4a2878; --ctp-surface2: #383468;
--ctp-overlay0: #5e3d90; --ctp-overlay0: #4a4580;
--ctp-overlay1: #7b50b8; --ctp-overlay1: #6460a8;
--ctp-overlay2: #9868d8; --ctp-overlay2: #8078c8;
/* Soft lavender text */ /* Text */
--ctp-text: #f0e6ff; --ctp-text: #e6e0f6;
--ctp-subtext1: #c9a8ff; --ctp-subtext1: #b09ee0;
--ctp-subtext0: #a882e0; --ctp-subtext0: #8878c0;
/* Neon accents */ /* Accents — muted synthwave palette */
--ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */ --ctp-mauve: #a06ae0; /* soft violet — primary accent */
--ctp-lavender: #bf5fff; /* electric purple — button gradient end */ --ctp-lavender: #7878d8; /* muted indigo — gradient end */
--ctp-pink: #ff2d78; --ctp-pink: #c060a8; /* dusty rose */
--ctp-flamingo: #ff6b9d; --ctp-flamingo: #c07090;
--ctp-rosewater: #ff8fc0; --ctp-rosewater: #c89090;
--ctp-blue: #00d4ff; /* electric cyan */ --ctp-blue: #58a8e8; /* steel blue */
--ctp-sapphire: #007aff; --ctp-sapphire: #4880c8;
--ctp-sky: #00bfff; --ctp-sky: #60c0d8;
--ctp-teal: #00e5cc; --ctp-teal: #40a8a8;
--ctp-green: #39ff14; /* neon green */ --ctp-green: #58c878; /* soft mint green */
--ctp-yellow: #ffd700; /* neon gold */ --ctp-yellow: #c8a848; /* muted gold */
--ctp-peach: #ff8c42; --ctp-peach: #c87848;
--ctp-maroon: #ff4d6d; --ctp-maroon: #c05868;
--ctp-red: #ff2d55; --ctp-red: #b04060;
/* ── Semantic tokens ── */ /* ── Semantic tokens ── */
--bg-app: #1c0b35; --bg-app: #161428;
--bg-sidebar: #160a28; --bg-sidebar: #110f22;
--bg-card: #2a1050; --bg-card: #1f1c38;
--bg-hover: #381860; --bg-hover: #2a2750;
--bg-player: #120720; --bg-player: #0d0c1a;
--bg-glass: rgba(28, 11, 53, 0.82); --bg-glass: rgba(22, 20, 40, 0.85);
--accent: #ff2d78; --accent: #a06ae0;
--accent-dim: rgba(255, 45, 120, 0.15); --accent-dim: rgba(160, 106, 224, 0.12);
--accent-glow: rgba(255, 45, 120, 0.45); --accent-glow: rgba(160, 106, 224, 0.30);
--text-primary: #f0e6ff; --text-primary: #e6e0f6;
--text-secondary:#c9a8ff; --text-secondary:#b09ee0;
--text-muted: #9b72d0; --text-muted: #706898;
--border: #4a2878; --border: #383468;
--border-subtle: #2a1050; --border-subtle: #1f1c38;
--positive: #39ff14; --positive: #58c878;
--warning: #ffd700; --warning: #c8a848;
--danger: #ff2d55; --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 ─── */ /* ─── Global Base Settings ─── */
@@ -525,6 +1251,8 @@
html, body, #root { html, body, #root {
height: 100%; height: 100%;
min-width: 1280px;
min-height: 720px;
overflow: hidden; overflow: hidden;
} }
@@ -616,7 +1344,7 @@ body.is-dragging * {
color: var(--ctp-crust); color: var(--ctp-crust);
font-weight: 600; 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-primary:active { transform: translateY(0); }
.btn-ghost { .btn-ghost {