From 0b1ed8cc5ac46236932f767544e9a6377e79c599 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sat, 21 Mar 2026 01:05:16 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.8.0=20=E2=80=94=20new=20themes,=20qu?= =?UTF-8?q?eue=20toolbar,=20lightbox,=20i18n=20(fr/nl)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 51 ++ CLAUDE.md | 56 +- README.md | 10 +- package.json | 2 +- packages/aur/PKGBUILD | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio.rs | 781 +++++++++++++---- src-tauri/src/lib.rs | 2 + src-tauri/tauri.conf.json | 6 +- src/App.tsx | 2 + src/api/lastfm.ts | 26 +- src/components/AlbumHeader.tsx | 23 +- src/components/ConnectionIndicator.tsx | 16 +- src/components/CoverLightbox.tsx | 28 + src/components/LastfmIndicator.tsx | 39 + src/components/NowPlayingDropdown.tsx | 16 +- src/components/PlayerBar.tsx | 2 +- src/components/QueuePanel.tsx | 139 +++- src/components/Sidebar.tsx | 2 +- src/components/ThemePicker.tsx | 155 ++++ src/components/WaveformSeek.tsx | 12 +- src/i18n.ts | 1060 +++++++++++++++++++++++- src/pages/ArtistDetail.tsx | 113 ++- src/pages/Help.tsx | 3 +- src/pages/Settings.tsx | 185 ++--- src/store/authStore.ts | 7 +- src/store/playerStore.ts | 37 +- src/store/themeStore.ts | 2 +- src/styles/components.css | 118 ++- src/styles/layout.css | 143 +++- src/styles/theme.css | 840 +++++++++++++++++-- 32 files changed, 3378 insertions(+), 504 deletions(-) create mode 100644 src/components/CoverLightbox.tsx create mode 100644 src/components/LastfmIndicator.tsx create mode 100644 src/components/ThemePicker.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5da271d4..2b3c6724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,57 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.8.0] - 2026-03-21 + +### Added + +#### Three New Themes +- **Poison**: Dark charcoal background (`#1a1a1a`) with phosphor green (`#1bd655`) accent — high-contrast, industrial aesthetic. LCD glow text-shadow on the now-playing track name. +- **Nucleo**: Warm brass/cream light theme inspired by vintage hi-fi equipment. Warm white cards, gold/amber accents, brushed-metal bevel buttons, and a warm LCD glow on the player track name. `color-scheme: light`. +- **Classic Winamp**: Cool gray-blue dark theme (`#2b2b3a`) channelling the classic Winamp 2.x skin. Yellow primary accent (`#d4cc46`), orange volume slider override (`--volume-accent: #de9b35`), Courier New monospace font with bright-green LCD glow for the track name. + +#### Psychowave Theme — Major Overhaul +- Psychowave recoloured from loud neon pink/purple to a refined deep violet palette: background `#161428`, accent `#a06ae0`. All neon colours replaced with muted, tasteful variants. No longer marked as WIP. + +#### ThemePicker Redesign +- Themes reorganised into semantic groups: **Catppuccin**, **Nord**, **Retro** (formerly Gruvbox), **Tokyo Night**, and a new **Psysonic Themes** section (Classic Winamp, Poison, Nucleo, Psychowave). The separate *Experimental* group is removed. +- "Gruvbox" renamed to **Retro**. + +#### Image Lightbox +- Clicking the **album cover** on an Album Detail page or the **artist avatar** on an Artist Detail page opens a full-screen lightbox showing the high-resolution image (up to 2000 px). Click outside or press Escape to close. +- Both use a shared `CoverLightbox` component — consistent behaviour across the app. + +#### Queue Toolbar — Complete Redesign +- The queue panel now has a **centred icon toolbar** with round buttons (border-radius 50%, solid accent fill when active): + - **Shuffle** — Fisher-Yates shuffle, keeps current track at position 0 + - **Save** — save queue as playlist + - **Load** — load a playlist into the queue + - **Clear** — remove all tracks from the queue + - **Gapless** (∞ icon) — toggle gapless playback on/off + - **Crossfade** (≋ icon) — toggle crossfade on/off; when inactive, clicking enables crossfade *and* opens a popover slider +- **Crossfade popover**: a small overlay below the Crossfade button with a range slider (1–10 s) to configure the fade duration. Clicking the active Crossfade button disables crossfade and closes the popover. Closes on outside click. +- **Queue header**: title enlarged to 16 px/700, track count and total duration shown inline next to the title in accent colour. Close (×) button removed. +- **Tech info overlay**: codec and bitrate displayed as a frosted glass badge (`backdrop-filter: blur(4px)`) overlaid on the bottom edge of the cover art image. + +#### French & Dutch Translations +- Full UI translation added for **French** (`fr`) and **Dutch** (`nl`) — all namespaces covered. +- Language selector in Settings now lists all four languages sorted alphabetically (Dutch, English, French, German). + +#### Help Page — Layout & Content Update +- **2-column grid layout** for the accordion — makes better use of horizontal space on widescreen displays. +- New Q&A entry: **Crossfade & Gapless** (Playback section) — explains what each feature does, how to enable them, and their experimental status. +- Updated entries: Themes (reflects all 21 themes), Languages (4 languages), Scrobbling (direct Last.fm), System browser links, Linux distribution (no AppImage). + +#### Settings — Experimental Labels +- Crossfade and Gapless toggles in Settings → Playback now show an **"Experimental"** badge next to their label. + +### Fixed + +- **Now Playing dropdown — refresh button**: The refresh icon spin was applied to the entire button, blocking clicks during the animation. Spin state is now separate from the background poll loading state — the button is always clickable, and the icon spins for a minimum of 600 ms for clear visual feedback. +- **Crossfade popover positioning**: Popover was overflowing the right edge of the viewport. Now right-aligned relative to the Crossfade button and positioned below it. + +--- + ## [1.7.2] - 2026-03-20 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 322c12da..9138c4a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build. - **Audio**: Rust/rodio engine (`src-tauri/src/audio.rs`) — downloads track bytes via reqwest, decodes with symphonia, plays via rodio. Replaces Howler.js. See detailed notes in the Notes section. - **API**: All server communication goes through `src/api/subsonic.ts` — a thin wrapper around axios using Subsonic token auth (MD5 hash of password + salt) - **Last.fm**: `src/api/lastfm.ts` — direct Last.fm API integration (scrobbling, Now Playing, love/unlove, similar artists, top stats, recent tracks). API key + secret from `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET` env vars (bundled at build time). -- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English + German) +- **i18n**: react-i18next, all translations inline in `src/i18n.ts` (English, German, French, Dutch) ### Key files @@ -50,14 +50,15 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build. | `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). | | `src/store/playerStore.ts` | Playback state, queue, scrobbling at 50% via Last.fm, server queue sync (debounced 1.5s). On `playTrack`: calls `reportNowPlaying` (Navidrome) + `lastfmUpdateNowPlaying` (Last.fm) independently. Persists `currentTrack`, `queue`, `queueIndex`, `currentTime` for cold-start resume. | | `src-tauri/src/audio.rs` | Rust audio engine: `audio_play`, `audio_pause`, `audio_resume`, `audio_stop`, `audio_seek`, `audio_set_volume` commands. Emits `audio:playing`, `audio:progress` (500ms), `audio:ended`, `audio:error` events. | -| `src/store/themeStore.ts` | Theme selection (9 themes), applied as `data-theme` on `` | +| `src/store/themeStore.ts` | Theme selection (21 themes), applied as `data-theme` on `` | | `src-tauri/src/lib.rs` | Tray menu, media key global shortcuts (disabled on Linux), `exit_app` command | | `src/App.tsx` | Root routing, `RequireAuth` guard, `TauriEventBridge` (media keys → store actions), `` mount | -| `src/i18n.ts` | All translations (en + de) inline. Language persisted in `localStorage('psysonic_language')`. | +| `src/i18n.ts` | All translations (en + de + fr + nl) inline. Language persisted in `localStorage('psysonic_language')`. | | `src/components/Sidebar.tsx` | Sidebar nav + `UpdateToast` component. On mount (1.5s delay) fetches `https://api.github.com/repos/Psychotoxical/psysonic/releases/latest`, compares `tag_name` against `version` imported directly from `package.json` (build-time constant — more reliable than `getVersion()` from Tauri API), and shows a toast above Statistics only when a newer version exists. Silently no-ops if offline. | | `src/components/AlbumHeader.tsx` | Extracted from AlbumDetail — cover art, album info, play/enqueue buttons, bio modal, download. | | `src/components/AlbumTrackList.tsx` | Extracted from AlbumDetail — tracklist with star ratings, codec labels, VA artist column, context menu. | -| `src/components/QueuePanel.tsx` | Queue sidebar. Shows song count + total duration below title. Items get `.context-active` class while their context menu is open. | +| `src/components/QueuePanel.tsx` | Queue sidebar. Toolbar with round buttons (Shuffle, Save, Load, Clear, Gapless ∞, Crossfade ≋). Header shows title + count + duration inline. Crossfade popover (range slider 1–10 s) anchored below the ≋ button. Tech info (codec/bitrate) as frosted-glass overlay on cover art. Items get `.context-active` class while their context menu is open. | +| `src/components/CoverLightbox.tsx` | Full-screen image lightbox. Props: `{ src, alt, onClose }`. ESC key + overlay click to close. Used in `AlbumHeader` and `ArtistDetail`. | | `packages/aur/PKGBUILD` | AUR package definition for Arch/CachyOS. Installs a wrapper script at `/usr/bin/psysonic` that sets `GDK_BACKEND=x11`, `WEBKIT_DISABLE_COMPOSITING_MODE=1`, `WEBKIT_DISABLE_DMABUF_RENDERER=1` before launching the binary. | ### Multi-server support @@ -106,19 +107,33 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL Add a function to `src/api/subsonic.ts` using the `api()` helper. The helper automatically injects auth params and unwraps `subsonic-response`. ### Themes -9 themes are available, selectable in Settings via `CustomSelect`. `themeStore` persists the choice and sets `data-theme` on ``. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set. +21 themes are available, selectable in Settings via `ThemePicker` (grouped). `themeStore` persists the choice and sets `data-theme` on ``. All component CSS uses semantic tokens (`--accent`, `--text-primary`, etc.) — only the player button gradient and a few decorative elements reference `--ctp-*` palette vars directly, so every theme must define the full `--ctp-*` set. -| Theme | Style | Accent | -|---|---|---| -| `mocha` | Catppuccin dark | Mauve | -| `macchiato` | Catppuccin medium-dark | Mauve | -| `frappe` | Catppuccin medium | Mauve | -| `latte` | Catppuccin light | Mauve | -| `nord` | Nord Polar Night dark | Frost `#88c0d0` | -| `nord-snowstorm` | Nord Snow Storm light | Deep-Blue `#5e81ac` | -| `nord-frost` | Nord deep ocean blue | Frost `#88c0d0` | -| `nord-aurora` | Nord Polar Night + aurora | Purple `#b48ead` | -| `psychowave` | Synthwave/retrowave deep purple | Purple `#c084fc` — WIP | +`--volume-accent` overrides the volume slider colour independently of `--accent` (used by Classic Winamp for orange volume, yellow accent elsewhere). + +| Theme | Group | Style | Accent | +|---|---|---|---| +| `mocha` | Catppuccin | dark | Mauve | +| `macchiato` | Catppuccin | medium-dark | Mauve | +| `frappe` | Catppuccin | medium | Mauve | +| `latte` | Catppuccin | light | Mauve | +| `nord` | Nord | Polar Night dark | Frost `#88c0d0` | +| `nord-snowstorm` | Nord | Snow Storm light | Deep-Blue `#5e81ac` | +| `nord-frost` | Nord | deep ocean blue | Frost `#88c0d0` | +| `nord-aurora` | Nord | Polar Night + aurora | Purple `#b48ead` | +| `gruvbox-dark-hard` | Retro | Gruvbox dark hard | Orange `#fe8019` | +| `gruvbox-dark-medium` | Retro | Gruvbox dark medium | Orange `#fe8019` | +| `gruvbox-dark-soft` | Retro | Gruvbox dark soft | Orange `#fe8019` | +| `gruvbox-light-hard` | Retro | Gruvbox light hard | Orange `#af3a03` | +| `gruvbox-light-medium` | Retro | Gruvbox light medium | Orange `#af3a03` | +| `gruvbox-light-soft` | Retro | Gruvbox light soft | Orange `#af3a03` | +| `tokyo-night` | Tokyo Night | dark blue | Purple `#7aa2f7` | +| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Purple `#7aa2f7` | +| `tokyo-night-light` | Tokyo Night | light | Purple `#7aa2f7` | +| `classic-winamp` | Psysonic | cool gray-blue dark, LCD glow, Courier New | Yellow `#d4cc46`, volume `#de9b35` | +| `poison` | Psysonic | dark charcoal, phosphor green LCD glow | Green `#1bd655` | +| `nucleo` | Psysonic | warm brass/cream light | Gold `#c8a860` | +| `psychowave` | Psysonic | deep violet synthwave | Purple `#a06ae0` | **Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm). @@ -132,7 +147,7 @@ Artist images are intentionally **not loaded** on the Artists overview page (gri `src/components/NowPlayingDropdown.tsx` polls `getNowPlaying` every 10 seconds in the background. Navidrome keeps stale "now playing" entries for several minutes after playback stops. To fix this: entries belonging to the current user (`ownUsername`) are filtered by the **local `isPlaying` state** from `playerStore` — so the badge disappears instantly when the user pauses or stops, without waiting for the server to clear the entry. Clicking an entry navigates to the album page (`/album/:albumId`) if `stream.albumId` is available. ### i18n -All German strings live exclusively in `src/i18n.ts` — never hardcode German in `.tsx` files. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`. +All non-English strings live exclusively in `src/i18n.ts` — never hardcode translated text in `.tsx` files. Four languages: `en`, `de`, `fr`, `nl`. Translation namespaces: `sidebar`, `home`, `hero`, `search`, `nowPlaying`, `contextMenu`, `albumDetail`, `artistDetail`, `favorites`, `randomMix`, `randomAlbums`, `playlists`, `albums`, `artists`, `statistics`, `login`, `common`, `settings`, `help`, `queue`, `player`. **German terminology**: "Queue" is always "Warteschlange" in German — never leave "Queue" untranslated in DE strings. @@ -181,7 +196,8 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **Playlists page**: List layout (not card grid) with sort buttons (Name / Tracks / Duration, toggle asc/desc) and a filter input. Play icon and delete button appear on row hover. - **Statistics page**: Library stat cards (Artists / Albums / Songs), Recently Played, Most Played, Highest Rated. Last.fm section (when configured): top artists/albums/tracks with period filter + recent scrobbles. No genre chart (removed). Data loaded in parallel via `Promise.allSettled`. - **Context menu**: `song` and `queue-item` types both have "Go to Album" (`Disc3` icon, shown only when `song.albumId` exists) and "Favorite" options. Context menu type union: `'song' | 'album' | 'artist' | 'queue-item' | 'album-song'`. -- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned. Default panel width 340 px. Header shows song count + total duration below the queue title. +- **QueuePanel meta box**: Shows title (no link) → artist (linked to `/artist/:id`) → album (linked to `/album/:id`) → year (if available). Cover art is 90×90 px, top-aligned with codec/bitrate frosted-glass overlay at bottom. Default panel width 340 px. Header: title 16px/700, song count + total duration inline in `--accent` colour. +- **Queue toolbar**: 6 round buttons (`queue-round-btn`, `border-radius: 50%`) centred in a row. Active state: `background: var(--accent); color: var(--ctp-base)`. Crossfade (≋) button: inactive click → enable + open popover; active click → disable + close. Popover: `position: absolute; top: calc(100% + 10px); right: 0; width: 170px` — right-aligned to prevent viewport overflow. - **Queue hover**: Queue items use `.context-active` CSS class when their context menu is open, keeping the hover highlight visible. - **Random Mix hover**: Row uses `.context-active` CSS class when a context menu is open for that song. `contextMenuSongId` state cleared via `useEffect` when `contextMenu.isOpen` becomes false. - **Favorites songs**: Full tracklist layout matching AlbumDetail — `track-row-va` grid with `#`, Title, Artist, Duration columns and a header row. Artist name clickable → artist page. "Add all to queue" button (`btn btn-surface`) next to the section title sends all favorited songs to the queue. @@ -192,4 +208,6 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **Tooltips**: Use `data-tooltip="text"` on any element — never native `title=`. `data-tooltip-pos="top|bottom|left|right"` (default: top). `data-tooltip-wrap` for multi-line. Rendered by `TooltipPortal` in `App.tsx` via `document.body` portal. - **Scrobbling**: At 50% playback, `playerStore` calls `lastfmScrobble()` directly. Navidrome is NOT used for scrobbling. Both `reportNowPlaying` (Navidrome) and `lastfmUpdateNowPlaying` (Last.fm) are called on every `playTrack()` — independently, fire-and-forget. - **Last.fm API key**: Stored in `.env` as `VITE_LASTFM_API_KEY` / `VITE_LASTFM_API_SECRET`. Bundled into the JS at build time (Vite). Not in git. For desktop apps this is acceptable — Last.fm's own docs acknowledge client-side keys can't be truly hidden. -- **Version**: 1.7.1 +- **NowPlayingDropdown refresh**: `spinning` state is separate from `loading` — button is always clickable. Spin lasts min 600 ms via `setTimeout`. Background poll (`loading`) does not block the button. +- **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). +- **Version**: 1.8.0 diff --git a/README.md b/README.md index f74aab41..afd19bee 100644 --- a/README.md +++ b/README.md @@ -22,9 +22,9 @@ Designed specifically for users hosting their own music via Navidrome or other S ## ✨ Features -- 🎨 **Gorgeous UI**: 9 deeply integrated themes (Catppuccin series, Nord series, Psychowave) with smooth glassmorphism effects and micro-animations. +- 🎨 **Gorgeous UI**: 21 deeply integrated themes — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, and Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) — with smooth glassmorphism effects and micro-animations. - ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps. -- 🌍 **Internationalization (i18n)**: Fully translated into English and German. +- 🌍 **Internationalization (i18n)**: Fully translated into English, German, French, and Dutch. - 📻 **Live "Now Playing"**: See what other users on your server are currently listening to in real-time. - 🎵 **Last.fm Integration** *(Beta)*: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required. - 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images. @@ -53,19 +53,17 @@ Designed specifically for users hosting their own music via Navidrome or other S - [x] Multi-server support - [x] IndexedDB image caching - [x] Random Mix with keyword filter & Super Genre mix -- [x] 9 themes: Catppuccin series, Nord series, Psychowave -- [x] Internationalization (English + German) +- [x] 21 themes: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Psysonic originals (Classic Winamp, Poison, Nucleo, Psychowave) +- [x] Internationalization (English, German, French, Dutch) - [x] AUR package (Arch / CachyOS) ### 🚧 In Progress -- [ ] **Psychowave theme** — visual refinement still ongoing - [ ] **Last.fm integration** — stabilising, moving out of beta - [ ] **Gapless playback** — edge case stabilisation - [ ] **Crossfade** — stability improvements ### 📋 Planned - [ ] FLAC seeking fix -- [ ] More themes - [ ] General UI polish & visual refinement - [ ] More languages diff --git a/package.json b/package.json index ea8cdfbf..0f6e6660 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.7.2", + "version": "1.8.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index 40a4d8e6..6e5597d8 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: stelle pkgname=psysonic -pkgver=1.7.2 +pkgver=1.8.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 36122189..6269ee04 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.7.0" +version = "1.8.0" dependencies = [ "biquad", "md5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47998d7a..eac0075b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.7.2" +version = "1.8.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index e071bdc2..f8c7037d 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -111,7 +111,7 @@ impl> Source for EqSource { fn total_duration(&self) -> Option { self.inner.total_duration() } fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { - // Reset biquad filter state to avoid glitches/clicks after seek. + // Reset biquad filter state to avoid glitches after seek. for band in 0..10 { let gain_db = f32::from_bits(self.gains[band].load(Ordering::Relaxed)); self.current_gains[band] = gain_db; @@ -133,41 +133,297 @@ impl> Source for EqSource { } } -// ─── Debug logger ───────────────────────────────────────────────────────────── +// ─── DynSource — type-erased Source wrapper ─────────────────────────────────── +// +// Allows chaining differently-typed sources (with trimming applied) into a +// single concrete type accepted by EqSource>. -// ─── Engine state (registered as Tauri managed state) ──────────────────────── +struct DynSource { + inner: Box + Send>, + channels: u16, + sample_rate: u32, +} + +impl DynSource { + fn new(src: impl Source + Send + 'static) -> Self { + let channels = src.channels(); + let sample_rate = src.sample_rate(); + Self { inner: Box::new(src), channels, sample_rate } + } +} + +impl Iterator for DynSource { + type Item = f32; + fn next(&mut self) -> Option { self.inner.next() } +} + +impl Source for DynSource { + fn current_frame_len(&self) -> Option { self.inner.current_frame_len() } + fn channels(&self) -> u16 { self.channels } + fn sample_rate(&self) -> u32 { self.sample_rate } + fn total_duration(&self) -> Option { self.inner.total_duration() } + fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + self.inner.try_seek(pos) + } +} + +// ─── EqualPowerFadeIn — per-sample sin(t·π/2) fade-in envelope ─────────────── +// +// Applied to every new track: +// • Crossfade: fade_dur = crossfade_secs → symmetric equal-power fade-in +// • Hard cut: fade_dur = 5 ms → micro-fade eliminates DC-click +// • Gapless: fade_dur = 0 → unity gain (no modification) +// +// gain(t) = sin(t · π/2), t ∈ [0, 1) +// At t = 0 gain = 0, at t = 1 gain = 1. +// Equal-power property: cos²+sin² = 1 → combined with cos fade-out on Track A +// the total perceived loudness stays constant across the crossfade. + +struct EqualPowerFadeIn> { + inner: S, + sample_count: u64, + fade_samples: u64, +} + +impl> EqualPowerFadeIn { + fn new(inner: S, fade_dur: Duration) -> Self { + let sample_rate = inner.sample_rate(); + let channels = inner.channels() as u64; + let fade_samples = if fade_dur.is_zero() { + 0 + } else { + (fade_dur.as_secs_f64() * sample_rate as f64 * channels as f64) as u64 + }; + Self { inner, sample_count: 0, fade_samples } + } +} + +impl> Iterator for EqualPowerFadeIn { + type Item = f32; + fn next(&mut self) -> Option { + let sample = self.inner.next()?; + let gain = if self.fade_samples == 0 || self.sample_count >= self.fade_samples { + 1.0 + } else { + let t = self.sample_count as f32 / self.fade_samples as f32; + (t * std::f32::consts::FRAC_PI_2).sin() + }; + self.sample_count += 1; + Some(sample * gain) + } +} + +impl> Source for EqualPowerFadeIn { + fn current_frame_len(&self) -> Option { self.inner.current_frame_len() } + fn channels(&self) -> u16 { self.inner.channels() } + fn sample_rate(&self) -> u32 { self.inner.sample_rate() } + fn total_duration(&self) -> Option { self.inner.total_duration() } + fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + // Restart the fade envelope after seeking (avoids a mid-song click if + // the user seeks to the very beginning while a fade was in progress). + self.sample_count = 0; + self.inner.try_seek(pos) + } +} + +// ─── NotifyingSource — sets a flag when the inner iterator is exhausted ─────── +// +// This is the key mechanism for gapless: the progress task polls `done` to know +// exactly when source N has finished inside the Sink, without relying on +// wall-clock estimation or the unreliable `Sink::empty()`. + +struct NotifyingSource> { + inner: S, + done: Arc, + signalled: bool, +} + +impl> NotifyingSource { + fn new(inner: S, done: Arc) -> Self { + Self { inner, done, signalled: false } + } +} + +impl> Iterator for NotifyingSource { + type Item = f32; + fn next(&mut self) -> Option { + let sample = self.inner.next(); + if sample.is_none() && !self.signalled { + self.signalled = true; + self.done.store(true, Ordering::SeqCst); + } + sample + } +} + +impl> Source for NotifyingSource { + fn current_frame_len(&self) -> Option { self.inner.current_frame_len() } + fn channels(&self) -> u16 { self.inner.channels() } + fn sample_rate(&self) -> u32 { self.inner.sample_rate() } + fn total_duration(&self) -> Option { self.inner.total_duration() } + fn try_seek(&mut self, pos: Duration) -> Result<(), rodio::source::SeekError> { + // If we seek backwards the source is no longer exhausted. + self.signalled = false; + self.done.store(false, Ordering::SeqCst); + self.inner.try_seek(pos) + } +} + +// ─── Encoder-gap trimming (iTunSMPB) ───────────────────────────────────────── +// +// MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent +// samples for LAME) and append end-padding to fill the final frame. +// iTunes embeds the exact counts in an ID3v2 COMM frame with description +// "iTunSMPB". Format: " 00000000 DELAY PADDING TOTAL ..." (space-separated hex) +// +// Parsing strategy: scan raw bytes for the ASCII marker, then extract the +// first whitespace-separated hex tokens after it. + +struct GaplessInfo { + delay_samples: u64, + total_valid_samples: Option, +} + +impl Default for GaplessInfo { + fn default() -> Self { + Self { delay_samples: 0, total_valid_samples: None } + } +} + +fn find_subsequence(data: &[u8], needle: &[u8]) -> Option { + data.windows(needle.len()).position(|w| w == needle) +} + +fn parse_gapless_info(data: &[u8]) -> GaplessInfo { + let pos = match find_subsequence(data, b"iTunSMPB") { + Some(p) => p, + None => return GaplessInfo::default(), + }; + + // Collect printable ASCII bytes after the tag (skip nulls / control chars) + let tail = &data[pos + 8..data.len().min(pos + 8 + 256)]; + let text: String = tail.iter() + .map(|&b| b as char) + .filter(|c| c.is_ascii_hexdigit() || *c == ' ') + .collect(); + + let parts: Vec<&str> = text.split_whitespace().collect(); + // parts[0] = "00000000", parts[1] = delay, parts[2] = padding, parts[3] = total + if parts.len() < 3 { + return GaplessInfo::default(); + } + let delay = u64::from_str_radix(parts.get(1).unwrap_or(&"0"), 16).unwrap_or(0); + let padding = u64::from_str_radix(parts.get(2).unwrap_or(&"0"), 16).unwrap_or(0); + let total_raw = parts.get(3).and_then(|s| u64::from_str_radix(s, 16).ok()); + + let total_valid = total_raw.map(|t| t).filter(|&t| t > 0).or_else(|| { + // Derive from delay + padding if total not available: + // Not possible without knowing total encoded samples, so just use None. + let _ = padding; + None + }); + + GaplessInfo { delay_samples: delay, total_valid_samples: total_valid } +} + +/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify. +/// +/// `fade_in_dur`: +/// • `Duration::ZERO` — unity gain; used for gapless chain (no click) +/// • `Duration::from_millis(5)` — micro-fade; used for hard cuts (anti-click) +/// • `Duration::from_secs_f32(cf)` — full equal-power fade-in for crossfade +fn build_source( + data: Vec, + duration_hint: f64, + eq_gains: Arc<[AtomicU32; 10]>, + eq_enabled: Arc, + done_flag: Arc, + fade_in_dur: Duration, +) -> Result<(NotifyingSource>>, f64), String> { + let gapless = parse_gapless_info(&data); + + let cursor = Cursor::new(data); + let decoder = Decoder::new(cursor).map_err(|e| e.to_string())?; + let sample_rate = decoder.sample_rate(); + + // Determine effective duration. + // Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3). + let effective_dur = if duration_hint > 1.0 { + duration_hint + } else { + decoder.total_duration() + .map(|d| d.as_secs_f64()) + .unwrap_or(duration_hint) + }; + + // Apply encoder-delay trim and optional end-padding trim. + let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() { + let delay_dur = Duration::from_secs_f64( + gapless.delay_samples as f64 / sample_rate as f64 + ); + let base = decoder.convert_samples::().skip_duration(delay_dur); + + if let Some(total) = gapless.total_valid_samples { + let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64); + DynSource::new(base.take_duration(valid_dur)) + } else { + DynSource::new(base) + } + } else { + DynSource::new(decoder.convert_samples::()) + }; + + let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled); + let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur); + let notifying = NotifyingSource::new(fade_in, done_flag); + + Ok((notifying, effective_dur)) +} + +// ─── Engine state ───────────────────────────────────────────────────────────── pub(crate) struct PreloadedTrack { url: String, data: Vec, - duration_hint: f64, +} + +/// Info about the track that has been appended (chained) to the current Sink +/// but whose source has not yet started playing (gapless mode only). +struct ChainedInfo { + /// The URL that was chained — used by audio_play to detect a pre-chain hit. + url: String, + duration_secs: f64, + replay_gain_linear: f32, + base_volume: f32, + /// Set by NotifyingSource when this chained track's source is exhausted. + source_done: Arc, } pub struct AudioEngine { pub stream_handle: Arc, pub current: Arc>, - /// Monotonically incremented on each audio_play / audio_stop call. - /// The background progress task captures its own `gen` at creation and - /// bails out if this counter has moved on, preventing stale events. + /// Monotonically incremented on each audio_play (non-chain) / audio_stop call. pub generation: Arc, pub http_client: reqwest::Client, pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, pub preloaded: Arc>>, pub crossfade_enabled: Arc, - pub crossfade_secs: Arc, // f32 stored as bits + pub crossfade_secs: Arc, pub fading_out_sink: Arc>>, + /// When true, audio_play chains sources to the existing Sink instead of + /// creating a new one, achieving sample-accurate gapless transitions. + pub gapless_enabled: Arc, + /// Info about the next-up chained track (gapless mode). + /// The progress task reads this when `current_source_done` fires. + pub chained_info: Arc>>, } pub struct AudioCurrent { - /// The active rodio Sink. `None` when stopped. pub sink: Option, pub duration_secs: f64, - /// Position (seconds) that we seeked/resumed from. pub seek_offset: f64, - /// Instant when we started counting from seek_offset (None when paused/stopped). pub play_started: Option, - /// Set when paused; holds the position at pause time. pub paused_at: Option, pub replay_gain_linear: f32, pub base_volume: f32, @@ -187,9 +443,6 @@ impl AudioCurrent { } } -/// Initialise the audio engine. Spawns a dedicated thread that holds the -/// `OutputStream` alive for the lifetime of the process (parking prevents -/// the thread — and thus the stream — from being dropped). pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { let (tx, rx) = std::sync::mpsc::sync_channel::(0); @@ -198,14 +451,9 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { .spawn(move || match rodio::OutputStream::try_default() { Ok((_stream, handle)) => { tx.send(handle).ok(); - // Park forever — `_stream` must stay alive for audio to work. - loop { - std::thread::park(); - } - } - Err(e) => { - eprintln!("[psysonic] audio output error: {e}"); + loop { std::thread::park(); } } + Err(e) => { eprintln!("[psysonic] audio output error: {e}"); } }) .expect("spawn audio stream thread"); @@ -233,6 +481,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { crossfade_enabled: Arc::new(AtomicBool::new(false)), crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())), fading_out_sink: Arc::new(Mutex::new(None)), + gapless_enabled: Arc::new(AtomicBool::new(false)), + chained_info: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -246,11 +496,59 @@ pub struct ProgressPayload { pub duration: f64, } +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/// Fetch track bytes from the preload cache or via HTTP. +async fn fetch_data( + url: &str, + state: &AudioEngine, + gen: u64, + app: &AppHandle, +) -> Result>, 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 = response.bytes().await.map_err(|e| e.to_string())?.into(); + Ok(Some(data)) +} + +fn compute_gain( + replay_gain_db: Option, + replay_gain_peak: Option, + volume: f32, +) -> (f32, f32) { + let gain_linear = replay_gain_db + .map(|db| 10f32.powf(db / 20.0)) + .unwrap_or(1.0); + let peak = replay_gain_peak.unwrap_or(1.0).max(0.001); + let gain_linear = gain_linear.min(1.0 / peak); + let effective = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0); + (gain_linear, effective) +} + // ─── Commands ───────────────────────────────────────────────────────────────── -/// Download and play the given URL. Replaces any currently playing track. -/// Emits `audio:playing` (with duration as f64) once playback starts, -/// then `audio:progress` every 500 ms, and `audio:ended` when done. #[tauri::command] pub async fn audio_play( url: String, @@ -261,115 +559,116 @@ pub async fn audio_play( app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { - // Claim this generation — any in-flight progress task with the old gen will exit. + let gapless = state.gapless_enabled.load(Ordering::Relaxed); + + // ── Gapless pre-chain hit ───────────────────────────────────────────────── + // audio_chain_preload already appended this URL to the Sink 30 s in + // advance. The source is live in the queue — just return and let the + // progress task handle the state transition when the previous source ends. + if gapless { + let already_chained = state.chained_info.lock().unwrap() + .as_ref() + .map(|c| c.url == url) + .unwrap_or(false); + if already_chained { + return Ok(()); + } + } + + // ── Standard (new-sink) path ───────────────────────────────────────────── + // Used for: manual skip, gapless OFF, first play, or gapless when the + // proactive chain was not set up in time. + let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1; - // Stop any previous fading sink unconditionally. - { - let mut fo = state.fading_out_sink.lock().unwrap(); - if let Some(old) = fo.take() { - old.stop(); - } + // Cancel any pending chain (manual skip while gapless chain was set up). + *state.chained_info.lock().unwrap() = None; + + // Stop fading-out sink from previous crossfade. + if let Some(old) = state.fading_out_sink.lock().unwrap().take() { + old.stop(); } - // NOTE: We intentionally do NOT stop the current Sink here. - // The old Sink keeps playing while we download + decode the new track. - // We swap Sinks atomically only after the new data is ready, so the - // old track plays to completion (or as close to it as possible). - - // ── Check preload cache or download ────────────────────────────────────── - let data: Vec = { - let cached = { - let mut preloaded = state.preloaded.lock().unwrap(); - if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) { - let d = p.data.clone(); - let _ = p.duration_hint; // used for type check - *preloaded = None; - Some(d) - } else { - None - } - }; - if let Some(cached_data) = cached { - cached_data - } else { - let response = state - .http_client - .get(&url) - .send() - .await - .map_err(|e| e.to_string())?; - if !response.status().is_success() { - let status = response.status().as_u16(); - if state.generation.load(Ordering::SeqCst) != gen { - return Ok(()); - } - let msg = format!("HTTP {status}"); - app.emit("audio:error", &msg).ok(); - return Err(msg); - } - response.bytes().await.map_err(|e| e.to_string())?.into() - } + // Fetch bytes (may use preload cache). + let data = match fetch_data(&url, &state, gen, &app).await? { + Some(d) => d, + None => return Ok(()), // superseded while downloading }; - // Bail if superseded while downloading. if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); } - // ── Decode ──────────────────────────────────────────────────────────────── + let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume); - // Trust the Subsonic API duration_hint as the primary source. - // Decoder::total_duration() is unreliable for VBR MP3 (symphonia may - // return a single-frame or header duration that is far too short). - let decoder_duration = { - let cursor = Cursor::new(data.clone()); - Decoder::new(cursor) - .ok() - .and_then(|d| d.total_duration()) - .map(|d| d.as_secs_f64()) - }; - let duration_secs = if duration_hint > 1.0 { - duration_hint - } else { - decoder_duration.unwrap_or(duration_hint) - }; - - let cursor = Cursor::new(data); - let decoder = Decoder::new(cursor).map_err(|e| { - app.emit("audio:error", e.to_string()).ok(); - e.to_string() - })?; - - // Final generation check before committing. - if state.generation.load(Ordering::SeqCst) != gen { - return Ok(()); - } - - // ── Compute replay gain ─────────────────────────────────────────────────── - let gain_linear: f32 = replay_gain_db - .map(|db| 10f32.powf(db / 20.0)) - .unwrap_or(1.0); - let peak = replay_gain_peak.unwrap_or(1.0).max(0.001); - // Limit gain so peak sample doesn't exceed 1.0 (prevent clipping). - let gain_linear = gain_linear.min(1.0 / peak); - let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0); - - // ── Create new sink ─────────────────────────────────────────────────────── let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed); let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0); - let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?; - sink.set_volume(effective_volume); - let eq_source = EqSource::new( - decoder.convert_samples::(), + // Measure how much audio Track A actually has left right now. + // By the time audio_play is called, near_end_ticks (2×500ms) + IPC latency + // have consumed ~500–800ms from Track A's tail — so its true remaining time + // is always less than crossfade_secs_val. Using the measured remaining time + // for BOTH fade-out (Track A) and fade-in (Track B) keeps them in sync and + // guarantees Track A reaches 0 exactly when its source exhausts. + let actual_fade_secs: f32 = if crossfade_enabled { + let cur = state.current.lock().unwrap(); + let remaining = (cur.duration_secs - cur.position()) as f32; + remaining.clamp(0.1, crossfade_secs_val) + } else { + 0.0 + }; + + // Fade-in duration for Track B: + // crossfade → equal-power sin(t·π/2) over actual remaining time of Track A + // hard cut → 5 ms micro-fade to suppress DC-offset click + let fade_in_dur = if crossfade_enabled { + Duration::from_secs_f32(actual_fade_secs) + } else { + Duration::from_millis(5) + }; + + // Build source: decode → trim → EQ → fade-in → notify. + let done_flag = Arc::new(AtomicBool::new(false)); + let (source, duration_secs) = build_source( + data, + duration_hint, state.eq_gains.clone(), state.eq_enabled.clone(), - ); - sink.append(eq_source); + done_flag.clone(), + fade_in_dur, + ).map_err(|e| { app.emit("audio:error", &e).ok(); e })?; - // Atomically swap sinks: take old one, install new one. - // Capture old volume so the crossfade fade-out starts at the right level. + if state.generation.load(Ordering::SeqCst) != gen { + return Ok(()); + } + + let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?; + sink.set_volume(effective_volume); + + // Gapless OFF: prepend a short silence so tracks are clearly separated. + // Only when this is an auto-advance (near end), not on manual skip. + if !gapless { + let cur_pos = { + let cur = state.current.lock().unwrap(); + cur.position() + }; + let cur_dur = { + let cur = state.current.lock().unwrap(); + cur.duration_secs + }; + let is_auto_advance = cur_dur > 3.0 && cur_pos >= cur_dur - 3.0; + if is_auto_advance { + let silence = rodio::source::Zero::::new( + source.channels(), + source.sample_rate(), + ).take_duration(Duration::from_millis(500)); + sink.append(silence); + } + } + + sink.append(source); + + // Atomically swap sinks. let (old_sink, old_vol) = { let mut cur = state.current.lock().unwrap(); let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0); @@ -384,25 +683,35 @@ pub async fn audio_play( (old, old_vol) }; - // Handle old sink: crossfade fade-out or immediate stop. - // The new track is already playing; the old sink runs in parallel during a crossfade. + // Handle old sink: equal-power crossfade or immediate stop. if crossfade_enabled { if let Some(old) = old_sink { - // Park the old sink in fading_out_sink so a subsequent audio_play can cancel it. *state.fading_out_sink.lock().unwrap() = Some(old); let fo_arc = state.fading_out_sink.clone(); tokio::spawn(async move { - let steps: u32 = 30; - let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64; - for i in (0..=steps).rev() { - { + // ~100 steps/sec (one step every 10 ms) for smooth equal-power fade. + // Duration = actual_fade_secs (Track A's measured remaining time), + // so the fade reaches exactly 0 when the source is exhausted. + const STEP_MS: u64 = 10; + let total_steps = ((actual_fade_secs * 1000.0) / STEP_MS as f32).round() as u32; + for i in (0..=total_steps).rev() { + let alive = { let fo = fo_arc.lock().unwrap(); match fo.as_ref() { - Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)), - None => return, // cancelled by a subsequent audio_play + Some(s) => { + // Equal-power cos curve: gain_a = cos(t · π/2) + // t goes 1→0 as i goes total_steps→0 + let t = i as f32 / total_steps as f32; + let gain = (t * std::f32::consts::FRAC_PI_2).cos(); + s.set_volume(old_vol * gain); + true + } + None => false, } - } - tokio::time::sleep(Duration::from_millis(step_ms)).await; + // MutexGuard dropped here before the await + }; + if !alive { return; } + tokio::time::sleep(Duration::from_millis(STEP_MS)).await; } if let Some(s) = fo_arc.lock().unwrap().take() { s.stop(); @@ -416,24 +725,141 @@ pub async fn audio_play( app.emit("audio:playing", duration_secs).ok(); // ── Progress + ended detection ──────────────────────────────────────────── - // We do NOT use `sink.empty()` because in rodio 0.19 the source moves from - // the pending queue to the active state almost immediately after `append()`, - // making `empty()` return `true` within milliseconds even for long tracks. - // - // Instead we use the wall-clock position (seek_offset + elapsed). - // `AudioCurrent::position()` is clamped to `duration_secs`, so once it - // reaches the end it stays there. We fire `audio:ended` after two - // consecutive ticks where position >= duration - threshold, where threshold - // is extended to crossfade_secs when crossfade is enabled so the frontend - // gets time to start the next track during the fade. - let gen_counter = state.generation.clone(); - let current_arc = state.current.clone(); - let app_clone = app.clone(); - let crossfade_enabled_arc = state.crossfade_enabled.clone(); - let crossfade_secs_arc = state.crossfade_secs.clone(); + spawn_progress_task( + gen, + state.generation.clone(), + state.current.clone(), + state.chained_info.clone(), + state.crossfade_enabled.clone(), + state.crossfade_secs.clone(), + done_flag, + app, + ); + Ok(()) +} + +/// Proactively appends the next track to the current Sink ~30 s before the +/// current track ends. Called from JS at the same trigger point as preload. +/// +/// Because this runs well before the track boundary, the IPC round-trip is +/// irrelevant — by the time the current track actually ends, the next source +/// is already live in the Sink queue and rodio transitions at sample accuracy. +/// +/// audio_play() checks chained_info.url on arrival: if it matches, it returns +/// immediately without touching the Sink (pure no-op on the audio path). +#[tauri::command] +pub async fn audio_chain_preload( + url: String, + volume: f32, + duration_hint: f64, + replay_gain_db: Option, + replay_gain_peak: Option, + 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 = { + 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` 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, + current_arc: Arc>, + chained_arc: Arc>>, + crossfade_enabled_arc: Arc, + crossfade_secs_arc: Arc, + initial_done: Arc, + app: AppHandle, +) { tokio::spawn(async move { let mut near_end_ticks: u32 = 0; + // Local done-flag reference; swapped on gapless transition. + let mut current_done = initial_done; loop { tokio::time::sleep(Duration::from_millis(500)).await; @@ -442,20 +868,43 @@ pub async fn audio_play( break; } + // ── Gapless transition detection ───────────────────────────────── + // If the current source is exhausted AND we have a chained track + // ready, transition seamlessly: swap tracking state, emit + // audio:playing for the new track, and continue the loop. + if current_done.load(Ordering::SeqCst) { + let chained = chained_arc.lock().unwrap().take(); + if let Some(info) = chained { + // Swap to the chained source's done flag. + current_done = info.source_done; + // Tracking was already updated in audio_play_gapless_chain; + // just update replay gain fields in case they differ. + { + let mut cur = current_arc.lock().unwrap(); + cur.replay_gain_linear = info.replay_gain_linear; + cur.base_volume = info.base_volume; + // Reset play_started to now — the old track physically + // ended, the new one is now actively producing samples. + cur.seek_offset = 0.0; + cur.play_started = Some(Instant::now()); + } + app.emit("audio:playing", info.duration_secs).ok(); + near_end_ticks = 0; + continue; + } + // Current source exhausted but no chain queued — the Sink is + // likely draining; audio:ended will fire on the next tick via + // the near-end logic below. + } + let (pos, dur, is_paused) = { let cur = current_arc.lock().unwrap(); (cur.position(), cur.duration_secs, cur.paused_at.is_some()) }; - app_clone - .emit( - "audio:progress", - ProgressPayload { current_time: pos, duration: dur }, - ) - .ok(); + app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); if is_paused { - // Don't advance near-end counter while paused (stay put). continue; } @@ -467,7 +916,7 @@ pub async fn audio_play( near_end_ticks += 1; if near_end_ticks >= 2 { gen_counter.fetch_add(1, Ordering::SeqCst); - app_clone.emit("audio:ended", ()).ok(); + app.emit("audio:ended", ()).ok(); break; } } else { @@ -475,8 +924,6 @@ pub async fn audio_play( } } }); - - Ok(()) } #[tauri::command] @@ -509,6 +956,7 @@ pub fn audio_resume(state: State<'_, AudioEngine>) { #[tauri::command] pub fn audio_stop(state: State<'_, AudioEngine>) { state.generation.fetch_add(1, Ordering::SeqCst); + *state.chained_info.lock().unwrap() = None; let mut cur = state.current.lock().unwrap(); if let Some(sink) = cur.sink.take() { sink.stop(); @@ -521,6 +969,15 @@ pub fn audio_stop(state: State<'_, AudioEngine>) { #[tauri::command] pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> { + // Seeking far back invalidates any pending gapless chain. + let cur_pos = { + let cur = state.current.lock().unwrap(); + cur.position() + }; + if seconds < cur_pos - 1.0 { + *state.chained_info.lock().unwrap() = None; + } + let mut cur = state.current.lock().unwrap(); if let Some(sink) = &cur.sink { sink.try_seek(Duration::from_secs_f64(seconds.max(0.0))) @@ -558,24 +1015,19 @@ pub async fn audio_preload( duration_hint: f64, state: State<'_, AudioEngine>, ) -> Result<(), String> { - // Don't re-download if already preloaded for this URL. { let preloaded = state.preloaded.lock().unwrap(); if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) { return Ok(()); } } - let response = state - .http_client - .get(&url) - .send() - .await - .map_err(|e| e.to_string())?; + let response = state.http_client.get(&url).send().await.map_err(|e| e.to_string())?; if !response.status().is_success() { - return Ok(()); // silently fail preload + return Ok(()); } let data: Vec = response.bytes().await.map_err(|e| e.to_string())?.into(); - *state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint }); + let _ = duration_hint; // kept in API for compatibility + *state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data }); Ok(()) } @@ -584,3 +1036,8 @@ pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngin state.crossfade_enabled.store(enabled, Ordering::Relaxed); state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed); } + +#[tauri::command] +pub fn audio_set_gapless(enabled: bool, state: State<'_, AudioEngine>) { + state.gapless_enabled.store(enabled, Ordering::Relaxed); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 28f0da0b..00a3f828 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -184,6 +184,8 @@ pub fn run() { audio::audio_set_eq, audio::audio_preload, audio::audio_set_crossfade, + audio::audio_set_gapless, + audio::audio_chain_preload, lastfm_request, ]) .run(tauri::generate_context!()) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d528c0fd..55588902 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.7.2", + "version": "1.8.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", @@ -16,8 +16,8 @@ "title": "Psysonic", "width": 1280, "height": 800, - "minWidth": 900, - "minHeight": 600, + "minWidth": 1280, + "minHeight": 720, "resizable": true, "fullscreen": false, "decorations": true, diff --git a/src/App.tsx b/src/App.tsx index d4b34b23..930c6a9c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -32,6 +32,7 @@ import ContextMenu from './components/ContextMenu'; import DownloadFolderModal from './components/DownloadFolderModal'; import TooltipPortal from './components/TooltipPortal'; import ConnectionIndicator from './components/ConnectionIndicator'; +import LastfmIndicator from './components/LastfmIndicator'; import OfflineOverlay from './components/OfflineOverlay'; import { useConnectionStatus } from './hooks/useConnectionStatus'; import { useAuthStore } from './store/authStore'; @@ -140,6 +141,7 @@ function AppShell() {
+
{coverUrl ? ( - + ) : (
)} diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index 7c989956..66dc3f58 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; import { ConnectionStatus } from '../hooks/useConnectionStatus'; interface Props { @@ -9,17 +10,24 @@ interface Props { export default function ConnectionIndicator({ status, isLan, serverName }: Props) { const { t } = useTranslation(); + const navigate = useNavigate(); const label = isLan ? 'LAN' : t('connection.extern'); - const title = + const tooltip = status === 'connected' - ? t('connection.connected') + ? t('connection.connectedTo', { server: serverName }) : status === 'disconnected' - ? t('connection.disconnected') + ? t('connection.disconnectedFrom', { server: serverName }) : t('connection.checking'); return ( -
+
navigate('/settings', { state: { tab: 'server' } })} + data-tooltip={tooltip} + data-tooltip-pos="bottom" + >
{label} diff --git a/src/components/CoverLightbox.tsx b/src/components/CoverLightbox.tsx new file mode 100644 index 00000000..5a01eec0 --- /dev/null +++ b/src/components/CoverLightbox.tsx @@ -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 ( +
+ + {alt} e.stopPropagation()} + /> +
+ ); +} diff --git a/src/components/LastfmIndicator.tsx b/src/components/LastfmIndicator.tsx new file mode 100644 index 00000000..2f9bf1f8 --- /dev/null +++ b/src/components/LastfmIndicator.tsx @@ -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 ( +
navigate('/settings', { state: { tab: 'server' } })} + data-tooltip={tooltip} + data-tooltip-pos="bottom" + > +
+
+ + + Last.fm + + + {lastfmSessionError ? t('connection.lastfmSessionInvalid').split(' —')[0] : `@${lastfmUsername}`} + +
+
+ ); +} diff --git a/src/components/NowPlayingDropdown.tsx b/src/components/NowPlayingDropdown.tsx index 770ecec2..3c1f4c96 100644 --- a/src/components/NowPlayingDropdown.tsx +++ b/src/components/NowPlayingDropdown.tsx @@ -14,6 +14,7 @@ export default function NowPlayingDropdown() { const isPlaying = usePlayerStore(s => s.isPlaying); const ownUsername = useAuthStore(s => s.getActiveServer()?.username ?? ''); const [loading, setLoading] = useState(false); + const [spinning, setSpinning] = useState(false); const dropdownRef = useRef(null); const fetchNowPlaying = async () => { @@ -28,6 +29,13 @@ export default function NowPlayingDropdown() { } }; + const handleRefresh = () => { + setSpinning(true); + fetchNowPlaying().finally(() => { + setTimeout(() => setSpinning(false), 600); + }); + }; + // Poll in background so the badge stays current without opening the dropdown useEffect(() => { fetchNowPlaying(); @@ -104,11 +112,11 @@ export default function NowPlayingDropdown() {

{t('nowPlaying.title')}

diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index c32bbb8f..7d09b864 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -40,7 +40,7 @@ export default function PlayerBar() { }, [setVolume]); const volumeStyle = { - background: `linear-gradient(to right, var(--ctp-mauve) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, + background: `linear-gradient(to right, var(--volume-accent, var(--accent)) ${volume * 100}%, var(--ctp-surface2) ${volume * 100}%)`, }; return ( diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 429488a9..94c0d5c7 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -1,10 +1,11 @@ import React, { useState, useRef } from 'react'; import { Track, usePlayerStore } from '../store/playerStore'; -import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle } from 'lucide-react'; +import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves } from 'lucide-react'; import { buildCoverArtUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { useAuthStore } from '../store/authStore'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -130,6 +131,30 @@ export default function QueuePanel() { const enqueue = usePlayerStore(s => s.enqueue); const contextMenu = usePlayerStore(s => s.contextMenu); + const crossfadeEnabled = useAuthStore(s => s.crossfadeEnabled); + const crossfadeSecs = useAuthStore(s => s.crossfadeSecs); + const gaplessEnabled = useAuthStore(s => s.gaplessEnabled); + const setCrossfadeEnabled = useAuthStore(s => s.setCrossfadeEnabled); + const setCrossfadeSecs = useAuthStore(s => s.setCrossfadeSecs); + const setGaplessEnabled = useAuthStore(s => s.setGaplessEnabled); + + const [showCrossfadePopover, setShowCrossfadePopover] = useState(false); + const crossfadeBtnRef = useRef(null); + const crossfadePopoverRef = useRef(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(null); const [dragOverIdx, setDragOverIdx] = useState(null); const isDraggingInternalRef = useRef(false); @@ -255,8 +280,8 @@ export default function QueuePanel() { }} >
-
-

{t('queue.title')}

+
+

{t('queue.title')}

{queue.length > 0 && (() => { const totalSecs = queue.reduce((acc, t) => acc + (t.duration || 0), 0); const h = Math.floor(totalSecs / 3600); @@ -266,31 +291,12 @@ export default function QueuePanel() { ? `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}` : `${m}:${s.toString().padStart(2, '0')}`; return ( -
+ {queue.length} {queue.length === 1 ? t('queue.trackSingular') : t('queue.trackPlural')} · {dur} -
+ ); })()}
- -
- - - - -
- -
{currentTrack && ( @@ -301,6 +307,13 @@ export default function QueuePanel() { ) : (
)} + {(currentTrack.bitRate || currentTrack.suffix) && ( +
+ {currentTrack.bitRate && currentTrack.suffix + ? `${currentTrack.bitRate} · ${currentTrack.suffix.toUpperCase()}` + : currentTrack.suffix?.toUpperCase() ?? ''} +
+ )}

{currentTrack.title}

@@ -319,20 +332,78 @@ export default function QueuePanel() { {currentTrack.year && (
{currentTrack.year}
)} -
-
- {currentTrack.bitRate && currentTrack.suffix ? ( - `${currentTrack.bitRate} kbps · ${currentTrack.suffix.toUpperCase()}` - ) : ( - currentTrack.suffix?.toUpperCase() ?? '' - )} -
- {renderStars(currentTrack.userRating)} -
+ {renderStars(currentTrack.userRating)}
)} +
+ + + + +
+ +
+ + {showCrossfadePopover && ( +
+
+ + {t('queue.crossfade')} + {crossfadeSecs}s +
+ { + setCrossfadeSecs(Number(e.target.value)); + setCrossfadeEnabled(true); + }} + className="crossfade-popover-slider" + /> +
+ 1s10s +
+
+ )} +
+
+ {currentTrack && queue.length > 0 &&
{t('queue.nextTracks')}
}
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 577c183f..45497488 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react'; const PsysonicLogo = () => ( - Psysonic Logo + Psysonic Logo ); const navItems = [ diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx new file mode 100644 index 00000000..eec46620 --- /dev/null +++ b/src/components/ThemePicker.tsx @@ -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 ( +
+ {THEME_GROUPS.map(({ group, themes }) => ( +
+
+ {group} +
+
+ {themes.map((t) => { + const isActive = value === t.id; + return ( + + ); + })} +
+
+ ))} +
+ ); +} diff --git a/src/components/WaveformSeek.tsx b/src/components/WaveformSeek.tsx index 97b539a5..f9d36ddf 100644 --- a/src/components/WaveformSeek.tsx +++ b/src/components/WaveformSeek.tsx @@ -59,8 +59,7 @@ function drawWaveform( ctx.clearRect(0, 0, w, h); const style = getComputedStyle(document.documentElement); - const colorBlue = style.getPropertyValue('--ctp-blue').trim() || '#89b4fa'; - const colorMauve = style.getPropertyValue('--ctp-mauve').trim() || '#cba6f7'; + const colorAccent = style.getPropertyValue('--accent').trim() || '#cba6f7'; const colorBuffered = style.getPropertyValue('--ctp-overlay0').trim() || '#6c7086'; const colorUnplayed = style.getPropertyValue('--ctp-surface1').trim() || '#313244'; @@ -97,14 +96,11 @@ function drawWaveform( ctx.fillRect(x, (h - barH) / 2, x2Of(i) - x, barH); } - // Pass 3 — played (gradient + glow) + // Pass 3 — played (accent color + glow) if (progress > 0) { - const grad = ctx.createLinearGradient(0, 0, progress * w, 0); - grad.addColorStop(0, colorBlue); - grad.addColorStop(1, colorMauve); ctx.globalAlpha = 1; - ctx.fillStyle = grad; - ctx.shadowColor = colorMauve; + ctx.fillStyle = colorAccent; + ctx.shadowColor = colorAccent; ctx.shadowBlur = 5; for (let i = 0; i < BAR_COUNT; i++) { if (i / BAR_COUNT >= progress) break; diff --git a/src/i18n.ts b/src/i18n.ts index 1e448bd4..859264db 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -222,12 +222,16 @@ const enTranslation = { }, connection: { connected: 'Connected', + connectedTo: 'Connected to {{server}}', disconnected: 'Disconnected', + disconnectedFrom: 'Cannot reach {{server}} — click to check settings', checking: 'Connecting…', extern: 'Extern', offlineTitle: 'No server connection', offlineSubtitle: 'Cannot reach {{server}}. Check your network or server.', retry: 'Retry', + lastfmConnected: 'Last.fm connected as @{{user}}', + lastfmSessionInvalid: 'Session invalid — click to re-connect', }, common: { albums: 'Albums', @@ -255,6 +259,8 @@ const enTranslation = { language: 'Language', languageEn: 'English', languageDe: 'German', + languageFr: 'French', + languageNl: 'Dutch', theme: 'Theme', appearance: 'Appearance', servers: 'Servers', @@ -342,6 +348,7 @@ const enTranslation = { crossfadeSecs: '{{n}}s', gapless: 'Gapless Playback', gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs', + experimental: 'Experimental', }, help: { title: 'Help', @@ -372,16 +379,16 @@ const enTranslation = { a11: 'The banner at the top of the home page randomly picks albums from your library and rotates through them every 10 seconds. Click the dots to jump to a specific one, or click the banner to open the album.', s4: 'Settings', q12: 'How do I change the theme?', - a12: 'Settings → Theme. Choose between Catppuccin Mocha (dark) and Catppuccin Latte (light).', + a12: 'Settings → Theme. Choose from 20+ themes across 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night, and the exclusive Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).', q13: 'How do I change the language?', - a13: 'Settings → Language. English and German are currently supported.', + a13: 'Settings → Language. English, German, French, and Dutch are supported.', q14: 'What does "Minimize to Tray" do?', a14: 'When enabled, clicking the × button hides Psysonic to the system tray instead of closing it. Music keeps playing. Right-click the tray icon for controls or to quit.', q15: 'How do I set a download folder?', a15: 'Settings → App Behavior → Download Folder. Pick any folder — downloaded albums are saved there as ZIP files. Without a custom folder, your browser\'s default downloads location is used.', s5: 'Scrobbling', q16: 'How does scrobbling work?', - a16: 'Psysonic uses your Navidrome server\'s built-in Last.fm integration. First, connect your Last.fm account in Navidrome\'s web interface. Then enable scrobbling in Psysonic\'s Settings.', + a16: 'Psysonic scrobbles directly to Last.fm — no Navidrome configuration needed. Connect your Last.fm account in Settings → Last.fm and enable scrobbling there.', q17: 'When is a scrobble sent?', a17: 'A scrobble is submitted after you\'ve listened to 50% of a track.', q22: 'What is the waveform in the player bar?', @@ -391,7 +398,7 @@ const enTranslation = { q24: 'Can I shuffle the queue?', a24: 'Yes. Open the queue panel and click the shuffle icon in the queue header. The currently playing track stays at position 1 — all remaining tracks are randomly reordered.', q25: 'Do Last.fm and Wikipedia links on artist pages open in a browser?', - a25: 'No — they open in a dedicated in-app window so you never leave Psysonic. The window shows the full website and can be closed independently.', + a25: 'Yes — they open in your default system browser. The button briefly shows a confirmation label when clicked.', s7: 'Random Mix', q26: 'What is Random Mix?', a26: 'Random Mix builds a playlist of random tracks from your entire library. Open it via "Random Mix" in the sidebar and click "New Mix". You can adjust the track count before generating.', @@ -404,10 +411,12 @@ const enTranslation = { a18: 'Images are fetched from your server\'s disk on first load and then cached locally for 30 days. If your server\'s storage is slow, the first visit to a page may take a moment. Subsequent visits will be instant.', q19: 'The connection test fails.', a19: 'Check the URL including the port (e.g. http://192.168.1.100:4533). Make sure no firewall blocks the connection. Try http:// instead of https:// on a local network. Also verify that your username and password are correct.', - q20: 'No audio on Linux (AppImage).', - a20: 'The AppImage bundles GStreamer. If audio still doesn\'t work, try installing system GStreamer packages: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.', - q21: 'The app crashes or shows a black screen on old Linux hardware.', - a21: 'This is usually caused by GPU / EGL driver issues in WebKitGTK. The official AppImage already patches the launcher to disable GPU compositing. If you build from source, launch with: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic', + q20: 'No audio on Linux.', + a20: 'Psysonic is available as .deb (Ubuntu/Debian), .rpm (Fedora/RHEL), and via AUR on Arch/CachyOS (yay -S psysonic). There is no AppImage. If audio is missing, ensure PipeWire or PulseAudio is running.', + q21: 'The app shows a black screen on Linux.', + a21: 'This is usually a GPU/EGL driver issue in WebKitGTK. Launch with GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. The AUR package and official .deb/.rpm installers set these automatically.', + q29: 'What are Crossfade and Gapless Playback?', + a29: 'Both are experimental audio features in Settings → Audio. Gapless pre-buffers the next track to eliminate silence between songs. Crossfade fades out the current track while fading in the next — adjust the duration (1–10 s) to taste.', }, queue: { title: 'Queue', @@ -423,6 +432,8 @@ const enTranslation = { deleteConfirm: 'Delete playlist "{{name}}"?', clear: 'Clear', shuffle: 'Shuffle queue', + gapless: 'Gapless', + crossfade: 'Crossfade', hide: 'Hide', close: 'Close', nextTracks: 'Next Tracks', @@ -712,12 +723,16 @@ const deTranslation = { }, connection: { connected: 'Verbunden', + connectedTo: 'Verbunden mit {{server}}', disconnected: 'Getrennt', + disconnectedFrom: '{{server}} nicht erreichbar — klicken für Einstellungen', checking: 'Verbinde…', extern: 'Extern', offlineTitle: 'Keine Serververbindung', offlineSubtitle: '{{server}} ist nicht erreichbar. Prüfe Netzwerk oder Server.', retry: 'Erneut versuchen', + lastfmConnected: 'Last.fm verbunden als @{{user}}', + lastfmSessionInvalid: 'Session ungültig — klicken zum Neu-Verbinden', }, common: { albums: 'Alben', @@ -745,6 +760,8 @@ const deTranslation = { language: 'Sprache', languageEn: 'Englisch', languageDe: 'Deutsch', + languageFr: 'Französisch', + languageNl: 'Niederländisch', theme: 'Design', appearance: 'Darstellung', servers: 'Server', @@ -832,6 +849,7 @@ const deTranslation = { crossfadeSecs: '{{n}}s', gapless: 'Nahtlose Wiedergabe', gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden', + experimental: 'Experimentell', }, help: { title: 'Hilfe', @@ -862,16 +880,16 @@ const deTranslation = { a11: 'Das Banner oben auf der Startseite wählt zufällige Alben aus der Bibliothek und rotiert alle 10 Sekunden weiter. Mit den Punkten kann man manuell springen, Klick auf das Banner öffnet das Album.', s4: 'Einstellungen', q12: 'Wie ändere ich das Theme?', - a12: 'Einstellungen → Theme. Wahl zwischen Catppuccin Mocha (dunkel) und Catppuccin Latte (hell).', + a12: 'Einstellungen → Design. Über 20 Designs in 6 Familien: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night und die exklusiven Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).', q13: 'Wie ändere ich die Sprache?', - a13: 'Einstellungen → Sprache. Aktuell verfügbar: Englisch und Deutsch.', + a13: 'Einstellungen → Sprache. Englisch, Deutsch, Französisch und Niederländisch werden unterstützt.', q14: 'Was bewirkt "In Tray minimieren"?', a14: 'Wenn aktiviert, versteckt sich Psysonic beim Klick auf × im System-Tray statt sich zu schließen. Die Musik läuft weiter. Rechtsklick auf das Tray-Icon zeigt Steueroptionen und "Beenden".', q15: 'Wie lege ich einen Download-Ordner fest?', a15: 'Einstellungen → App-Verhalten → Download-Ordner. Beliebigen Ordner wählen — heruntergeladene Alben werden dort als ZIP gespeichert. Ohne eigenen Ordner landet alles im Standard-Downloads-Ordner.', s5: 'Scrobbling', q16: 'Wie funktioniert Scrobbling?', - a16: 'Psysonic nutzt die eingebaute Last.fm-Integration von Navidrome. Zuerst das Last.fm-Konto im Navidrome-Webinterface verbinden, dann Scrobbling in den Psysonic-Einstellungen aktivieren.', + a16: 'Psysonic scrobbled direkt zu Last.fm — keine Navidrome-Konfiguration nötig. Last.fm-Konto in Einstellungen → Last.fm verbinden und Scrobbling dort aktivieren.', q17: 'Wann wird ein Scrobble gesendet?', a17: 'Ein Scrobble wird übermittelt, wenn 50 % eines Tracks gehört wurden.', q22: 'Was ist die Waveform in der Playerleiste?', @@ -881,7 +899,7 @@ const deTranslation = { q24: 'Kann ich die Warteschlange mischen?', a24: 'Ja. Die Warteschlange öffnen und auf das Shuffle-Icon im Header klicken. Der aktuell laufende Track bleibt an Position 1 — alle restlichen Tracks werden zufällig neu geordnet.', q25: 'Öffnen Last.fm- und Wikipedia-Links auf Künstlerseiten im Browser?', - a25: 'Nein — sie öffnen sich in einem eigenen In-App-Fenster, sodass du Psysonic nie verlassen musst. Das Fenster zeigt die vollständige Website und kann unabhängig geschlossen werden.', + a25: 'Ja — sie öffnen sich im Standard-Systembrowser. Der Button zeigt kurz eine Bestätigungsmeldung beim Klick.', s7: 'Zufallsmix', q26: 'Was ist der Zufallsmix?', a26: 'Der Zufallsmix erstellt eine Playlist aus zufälligen Tracks deiner gesamten Bibliothek. Über "Zufallsmix" in der Seitenleiste erreichbar, dann "Neuer Mix" klicken. Die Anzahl der Tracks ist vor der Generierung einstellbar.', @@ -894,10 +912,12 @@ const deTranslation = { a18: 'Bilder werden beim ersten Aufruf vom Server geholt und dann 30 Tage lokal gecacht. Bei langsamen Server-Festplatten kann der erste Seitenaufruf einen Moment dauern. Danach geht alles sofort.', q19: 'Der Verbindungstest schlägt fehl.', a19: 'URL inklusive Port prüfen (z. B. http://192.168.1.100:4533). Sicherstellen, dass keine Firewall die Verbindung blockiert. Im lokalen Netzwerk http:// statt https:// versuchen. Benutzername und Passwort kontrollieren.', - q20: 'Kein Ton unter Linux (AppImage).', - a20: 'Das AppImage bündelt GStreamer. Falls trotzdem kein Ton kommt, diese System-Pakete installieren: gstreamer1.0-plugins-good, gstreamer1.0-plugins-bad, gstreamer1.0-libav.', - q21: 'Die App stürzt ab oder zeigt einen schwarzen Bildschirm auf alter Linux-Hardware.', - a21: 'Das liegt meist an GPU/EGL-Treiberproblemen in WebKitGTK. Das offizielle AppImage deaktiviert GPU-Compositing bereits automatisch. Beim Selbst-Kompilieren: WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 ./psysonic', + q20: 'Kein Ton unter Linux.', + a20: 'Psysonic ist als .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) und über AUR für Arch/CachyOS verfügbar (yay -S psysonic). Es gibt kein AppImage. Falls kein Ton kommt, sicherstellen, dass PipeWire oder PulseAudio läuft.', + q21: 'Die App zeigt einen schwarzen Bildschirm unter Linux.', + a21: 'Meist ein GPU/EGL-Treiberproblem in WebKitGTK. Mit GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 starten. Das AUR-Paket und die offiziellen .deb/.rpm-Pakete setzen diese Variablen automatisch.', + q29: 'Was sind Crossfade und nahtlose Wiedergabe?', + a29: 'Beides sind experimentelle Audiofunktionen in Einstellungen → Audio. Nahtlose Wiedergabe puffert den nächsten Track vor, um Stille zu eliminieren. Crossfade blendet den aktuellen Track aus, während der nächste eingeblendet wird — Dauer (1–10 s) einstellbar.', }, queue: { title: 'Warteschlange', @@ -913,6 +933,8 @@ const deTranslation = { deleteConfirm: 'Playlist "{{name}}" löschen?', clear: 'Leeren', shuffle: 'Warteschlange mischen', + gapless: 'Nahtlos', + crossfade: 'Crossfade', hide: 'Verbergen', close: 'Schließen', nextTracks: 'Nächste Titel', @@ -981,6 +1003,1008 @@ const deTranslation = { } }; +// French Translations +const frTranslation = { + sidebar: { + library: 'Bibliothèque', + mainstage: 'Scène principale', + newReleases: 'Nouveautés', + allAlbums: 'Tous les albums', + randomAlbums: 'Albums aléatoires', + artists: 'Artistes', + playlists: 'Listes de lecture', + randomMix: 'Mix aléatoire', + favorites: 'Favoris', + nowPlaying: 'En cours', + system: 'Système', + statistics: 'Statistiques', + settings: 'Paramètres', + help: 'Aide', + expand: 'Développer la barre latérale', + collapse: 'Réduire la barre latérale', + updateAvailable: 'Mise à jour disponible', + updateReady: '{{version}} est prêt', + updateLink: 'Voir la version →' + }, + home: { + starred: 'Favoris personnels', + recent: 'Ajoutés récemment', + mostPlayed: 'Les plus écoutés', + discover: 'Découvrir', + loadMore: 'Charger plus', + discoverMore: 'Découvrir plus' + }, + hero: { + eyebrow: 'Album en vedette', + playAlbum: 'Lire l\'album', + enqueue: 'Mettre en file', + enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente', + }, + search: { + placeholder: 'Rechercher un artiste, album ou morceau…', + noResults: 'Aucun résultat pour « {{query}} »', + artists: 'Artistes', + albums: 'Albums', + songs: 'Morceaux', + clearLabel: 'Effacer la recherche', + title: 'Recherche', + resultsFor: 'Résultats pour « {{query}} »', + album: 'Album', + }, + nowPlaying: { + tooltip: 'Qui écoute ?', + title: 'Qui écoute ?', + loading: 'Chargement…', + nobody: 'Personne n\'écoute en ce moment.', + minutesAgo: 'il y a {{n}} min', + nothingPlaying: 'Rien en cours. Lancez un morceau !', + }, + contextMenu: { + playNow: 'Lire maintenant', + playNext: 'Lire ensuite', + addToQueue: 'Ajouter à la file', + enqueueAlbum: 'Mettre l\'album en file', + startRadio: 'Démarrer la radio', + lfmLove: 'Aimer sur Last.fm', + lfmUnlove: 'Ne plus aimer sur Last.fm', + favorite: 'Favori', + favoriteArtist: 'Artiste favori', + favoriteAlbum: 'Album favori', + removeFromQueue: 'Retirer de la file', + openAlbum: 'Ouvrir l\'album', + goToArtist: 'Aller à l\'artiste', + download: 'Télécharger (ZIP)', + }, + albumDetail: { + back: 'Retour', + playAll: 'Tout lire', + enqueue: 'Mettre en file', + enqueueTooltip: 'Ajouter l\'album entier à la file d\'attente', + artistBio: 'Biographie', + download: 'Télécharger (ZIP)', + downloading: 'Chargement…', + downloadHint: 'Les albums FLAC/WAV sont zippés côté serveur — les grands albums peuvent prendre un moment avant le démarrage du téléchargement.', + downloadHintShort: 'Le serveur zippe d\'abord — peut prendre un moment selon la taille', + favoriteAdd: 'Ajouter aux favoris', + favoriteRemove: 'Retirer des favoris', + favorite: 'Favori', + noBio: 'Aucune biographie disponible.', + moreByArtist: 'Plus de {{artist}}', + tracksCount: '{{n}} pistes', + goToArtist: 'Aller à {{artist}}', + moreLabelAlbums: 'Plus d\'albums sur {{label}}', + trackTitle: 'Titre', + trackArtist: 'Artiste', + trackFormat: 'Format', + trackFavorite: 'Favori', + trackRating: 'Note', + trackDuration: 'Durée', + trackTotal: 'Total', + notFound: 'Album introuvable.', + bioModal: 'Biographie de l\'artiste', + bioClose: 'Fermer', + ratingLabel: 'Note', + }, + artistDetail: { + back: 'Retour', + albums: 'Albums', + album: 'Album', + playAll: 'Tout lire', + shuffle: 'Aléatoire', + radio: 'Radio', + loading: 'Chargement…', + noRadio: 'Aucun morceau similaire trouvé pour cet artiste.', + notFound: 'Artiste introuvable.', + albumsBy: 'Albums de {{name}}', + topTracks: 'Morceaux populaires', + noAlbums: 'Aucun album trouvé.', + trackTitle: 'Titre', + trackAlbum: 'Album', + trackDuration: 'Durée', + favoriteAdd: 'Ajouter aux favoris', + favoriteRemove: 'Retirer des favoris', + favorite: 'Favori', + albumCount_one: '{{count}} album', + albumCount_other: '{{count}} albums', + openedInBrowser: 'Ouvert dans le navigateur', + featuredOn: 'Également sur', + similarArtists: 'Artistes similaires', + }, + favorites: { + title: 'Favoris', + empty: 'Vous n\'avez pas encore de favoris.', + artists: 'Artistes', + albums: 'Albums', + songs: 'Morceaux', + enqueueAll: 'Tout ajouter à la file', + }, + randomAlbums: { + title: 'Albums aléatoires', + refresh: 'Actualiser', + }, + randomMix: { + title: 'Mix aléatoire', + remix: 'Remixer', + remixTooltip: 'Charger de nouveaux morceaux aléatoires', + playAll: 'Tout lire', + trackTitle: 'Titre', + trackArtist: 'Artiste', + trackAlbum: 'Album', + trackFavorite: 'Favori', + trackDuration: 'Durée', + favoriteAdd: 'Ajouter aux favoris', + favoriteRemove: 'Retirer des favoris', + play: 'Lire', + trackGenre: 'Genre', + excludeAudiobooks: 'Exclure les livres audio et pièces radiophoniques', + excludeAudiobooksDesc: 'Correspond aux mots-clés dans le genre, le titre et l\'album — ex. Hörbuch, Audiobook, Spoken Word, …', + genreBlocked: 'Mot-clé bloqué', + genreAddedToBlacklist: 'Ajouté à la liste de filtres', + genreAlreadyBlocked: 'Déjà bloqué', + blacklistToggle: 'Filtre par mot-clé', + genreMixTitle: 'Mix par genre', + genreMixDesc: 'Sélectionnez un genre pour obtenir un mix aléatoire', + genreMixLoadMore: 'Charger 10 de plus', + genreMixNoGenres: 'Aucun genre trouvé sur le serveur.', + filterPanelTitle: 'Filtres', + genreClickHint: 'Cliquez sur un tag de genre pour l\'ajouter\ncomme mot-clé de filtre.\nCorrespond au genre, titre et album.', + }, + playlists: { + title: 'Listes de lecture', + loading: 'Chargement des listes…', + empty: 'Aucune liste de lecture.\nUtilisez la file d\'attente pour en créer.', + play: 'Lire', + deleteTooltip: 'Supprimer', + confirmDelete: 'Supprimer la liste « {{name}} » ?', + minutes: 'min.', + track_one: '{{count}} piste', + track_other: '{{count}} pistes', + filterPlaceholder: 'Filtrer les listes…', + colName: 'Nom', + colTracks: 'Pistes', + colDuration: 'Durée', + noResults: 'Aucune liste ne correspond au filtre.', + }, + albums: { + title: 'Tous les albums', + sortByName: 'A–Z (Album)', + sortByArtist: 'A–Z (Artiste)', + sortNewest: 'Plus récents', + sortRandom: 'Aléatoire', + }, + artists: { + title: 'Artistes', + search: 'Rechercher…', + all: 'Tous', + gridView: 'Vue en grille', + listView: 'Vue en liste', + loadMore: 'Charger plus', + notFound: 'Aucun artiste trouvé.', + albumCount_one: '{{count}} album', + albumCount_other: '{{count}} albums', + }, + login: { + subtitle: 'Votre lecteur de bureau Navidrome', + serverName: 'Nom du serveur (facultatif)', + serverNamePlaceholder: 'Mon Navidrome', + serverUrl: 'URL du serveur', + serverUrlPlaceholder: '192.168.1.100:4533 ou music.exemple.com', + username: 'Nom d\'utilisateur', + usernamePlaceholder: 'admin', + password: 'Mot de passe', + showPassword: 'Afficher le mot de passe', + hidePassword: 'Masquer le mot de passe', + connect: 'Se connecter', + connecting: 'Connexion…', + connected: 'Connecté !', + error: 'Connexion échouée — vérifiez vos informations.', + urlRequired: 'Veuillez saisir une URL de serveur.', + savedServers: 'Serveurs enregistrés', + addNew: 'Ou ajouter un nouveau serveur', + }, + connection: { + connected: 'Connecté', + connectedTo: 'Connecté à {{server}}', + disconnected: 'Déconnecté', + disconnectedFrom: 'Impossible d\'atteindre {{server}} — cliquez pour vérifier les paramètres', + checking: 'Connexion…', + extern: 'Externe', + offlineTitle: 'Pas de connexion au serveur', + offlineSubtitle: 'Impossible d\'atteindre {{server}}. Vérifiez votre réseau ou le serveur.', + retry: 'Réessayer', + lastfmConnected: 'Last.fm connecté en tant que @{{user}}', + lastfmSessionInvalid: 'Session invalide — cliquez pour vous reconnecter', + }, + common: { + albums: 'Albums', + album: 'Album', + loading: 'Chargement…', + loadingMore: 'Chargement…', + loadingPlaylists: 'Chargement des listes…', + noAlbums: 'Aucun album trouvé.', + downloading: 'Téléchargement…', + downloadZip: 'Télécharger (ZIP)', + back: 'Retour', + cancel: 'Annuler', + save: 'Enregistrer', + delete: 'Supprimer', + use: 'Utiliser', + add: 'Ajouter', + active: 'Actif', + download: 'Télécharger', + chooseDownloadFolder: 'Choisir le dossier de téléchargement', + noFolderSelected: 'Aucun dossier sélectionné', + rememberDownloadFolder: 'Mémoriser ce dossier', + }, + settings: { + title: 'Paramètres', + language: 'Langue', + languageEn: 'Anglais', + languageDe: 'Allemand', + languageFr: 'Français', + languageNl: 'Néerlandais', + theme: 'Thème', + appearance: 'Apparence', + servers: 'Serveurs', + serverName: 'Nom du serveur', + serverUrl: 'URL du serveur', + serverUsername: 'Nom d\'utilisateur', + serverPassword: 'Mot de passe', + addServer: 'Ajouter un serveur', + addServerTitle: 'Ajouter un nouveau serveur', + useServer: 'Utiliser', + deleteServer: 'Supprimer', + noServers: 'Aucun serveur enregistré.', + serverActive: 'Actif', + confirmDeleteServer: 'Supprimer le serveur « {{name}} » ?', + serverConnecting: 'Connexion…', + serverConnected: 'Connecté !', + serverFailed: 'Connexion échouée.', + testBtn: 'Tester la connexion', + testingBtn: 'Test en cours…', + connected: 'Connecté', + failed: 'Échec', + eqTitle: 'Égaliseur', + eqEnabled: 'Activer l\'égaliseur', + eqPreset: 'Préréglage', + eqPresetCustom: 'Personnalisé', + eqPresetBuiltin: 'Préréglages intégrés', + eqPresetCustomGroup: 'Mes préréglages', + eqSavePreset: 'Enregistrer comme préréglage', + eqPresetName: 'Nom du préréglage…', + eqDeletePreset: 'Supprimer le préréglage', + eqResetBands: 'Réinitialiser à plat', + lfmTitle: 'Last.fm', + lfmConnect: 'Connecter avec Last.fm', + lfmConnecting: 'En attente d\'autorisation…', + lfmConfirm: 'J\'ai autorisé l\'application', + lfmConnected: 'Connecté en tant que', + lfmDisconnect: 'Déconnecter', + lfmConnectDesc: 'Connectez votre compte Last.fm pour activer le scrobbling et les mises à jour « En cours de lecture » depuis Psysonic — aucune configuration Navidrome requise.', + lfmOpenBrowser: 'Une fenêtre de navigateur s\'ouvrira. Autorisez Psysonic sur Last.fm, puis cliquez sur le bouton ci-dessous.', + lfmScrobbles: '{{n}} scrobbles', + lfmMemberSince: 'Membre depuis {{year}}', + scrobbleEnabled: 'Scrobbling activé', + scrobbleDesc: 'Envoyer les morceaux à Last.fm après 50% d\'écoute', + behavior: 'Comportement de l\'application', + trayTitle: 'Réduire dans la barre système', + trayDesc: 'Réduire l\'application dans la barre système à la fermeture (X)', + cacheTitle: 'Taille max. du cache', + cacheDesc: 'Pour les pistes préchargées', + downloadsTitle: 'Dossier de téléchargement', + downloadsDefault: 'Dossier de téléchargement par défaut', + pickFolder: 'Sélectionner', + pickFolderTitle: 'Sélectionner le dossier de téléchargement', + clearFolder: 'Effacer le dossier', + logout: 'Se déconnecter', + aboutTitle: 'À propos de Psysonic', + aboutDesc: 'Un lecteur de musique de bureau pour les serveurs compatibles Subsonic (Navidrome, Gonic, et autres). Diffuse votre bibliothèque musicale auto-hébergée avec une interface moderne et épurée.', + aboutFeatures: 'Multi-serveur · Scrobbling · Lecteur plein écran · Téléchargement d\'albums · Cache d\'images · Thèmes Catppuccin', + aboutLicense: 'Licence', + aboutLicenseText: 'GNU GPL v3 — libre d\'utilisation, de modification et de distribution sous la même licence.', + aboutRepo: 'Code source sur GitHub', + aboutVersion: 'Version', + aboutBuiltWith: 'Construit avec Tauri · React · TypeScript · Rust/rodio', + aboutAiCredit: 'Développé avec le soutien de Claude Code par Anthropic', + changelog: 'Journal des modifications', + randomMixTitle: 'Mix aléatoire', + randomMixBlacklistTitle: 'Mots-clés de filtre personnalisés', + randomMixBlacklistDesc: 'Les morceaux sont exclus si un mot-clé correspond à leur genre, titre ou album (actif quand la case ci-dessus est cochée).', + randomMixBlacklistPlaceholder: 'Ajouter un mot-clé…', + randomMixBlacklistAdd: 'Ajouter', + randomMixBlacklistEmpty: 'Aucun mot-clé personnalisé ajouté.', + randomMixHardcodedTitle: 'Mots-clés intégrés (actifs quand la case est cochée)', + tabPlayback: 'Lecture', + tabLibrary: 'Bibliothèque', + tabAppearance: 'Apparence', + tabServer: 'Serveur', + tabAbout: 'À propos', + playbackTitle: 'Lecture', + replayGain: 'Replay Gain', + replayGainDesc: 'Normaliser le volume des pistes avec les métadonnées ReplayGain', + replayGainMode: 'Mode', + replayGainTrack: 'Piste', + replayGainAlbum: 'Album', + crossfade: 'Fondu enchaîné', + crossfadeDesc: 'Fondu entre les pistes', + crossfadeSecs: '{{n}}s', + gapless: 'Lecture sans blanc', + gaplessDesc: 'Préparer la piste suivante pour éliminer les silences entre les morceaux', + experimental: 'Expérimental', + }, + help: { + title: 'Aide', + s1: 'Démarrage', + q1: 'Quels serveurs sont compatibles ?', + a1: 'Psysonic fonctionne avec tout serveur compatible Subsonic : Navidrome, Gonic, Subsonic, Airsonic, et autres. Navidrome est recommandé.', + q2: 'Comment me connecter à mon serveur ?', + a2: 'Ouvrez les Paramètres et cliquez sur « Ajouter un serveur ». Saisissez l\'URL du serveur (ex. 192.168.1.100:4533), votre nom d\'utilisateur et mot de passe. Psysonic teste la connexion avant d\'enregistrer.', + q3: 'Puis-je utiliser plusieurs serveurs ?', + a3: 'Oui. Vous pouvez ajouter autant de serveurs que vous le souhaitez dans les Paramètres et basculer entre eux à tout moment. Un seul serveur est actif à la fois.', + s2: 'Lecture', + q4: 'Comment lire de la musique ?', + a4: 'Double-cliquez sur une piste pour la lire. Sur les pages d\'album et d\'artiste, utilisez « Tout lire » pour démarrer l\'album entier. Vous pouvez aussi glisser des pistes dans la file d\'attente.', + q5: 'Quels raccourcis clavier sont disponibles ?', + a5: 'Espace = Lecture / Pause · Échap = Fermer le lecteur plein écran. Les touches multimédia fonctionnent sur macOS et Windows. Sur Linux, utilisez les boutons de la barre du lecteur.', + q6: 'Qu\'est-ce que la file d\'attente ?', + a6: 'La file d\'attente affiche toutes les pistes à venir. Ouvrez-la avec l\'icône de panneau en haut à droite. Vous pouvez réorganiser les pistes par glisser-déposer, les mélanger et enregistrer la file comme liste de lecture.', + q7: 'Comment ouvrir le lecteur plein écran ?', + a7: 'Cliquez sur la pochette dans la barre du lecteur en bas, ou sur l\'icône d\'agrandissement à côté. Appuyez sur Échap pour fermer.', + q8: 'Comment fonctionne la répétition ?', + a8: 'Cliquez sur le bouton répéter pour alterner : Désactivé → Répéter tout → Répéter un.', + s3: 'Bibliothèque', + q9: 'Comment télécharger un album ?', + a9: 'Ouvrez la page de détail d\'un album et cliquez sur « Télécharger (ZIP) ». Le serveur zippe d\'abord l\'album — cela peut prendre un moment pour les grands albums ou les fichiers sans perte.', + q10: 'Comment mettre des pistes et albums en favoris ?', + a10: 'Cliquez sur l\'icône étoile sur une piste ou dans l\'en-tête de l\'album. Les éléments favoris apparaissent dans la section Favoris de la barre latérale.', + q11: 'Qu\'est-ce que le carousel hero sur la page d\'accueil ?', + a11: 'La bannière en haut de la page d\'accueil sélectionne aléatoirement des albums de votre bibliothèque et les fait défiler toutes les 10 secondes. Cliquez sur les points pour accéder à un album spécifique.', + s4: 'Paramètres', + q12: 'Comment changer le thème ?', + a12: 'Paramètres → Thème. Plus de 20 thèmes en 6 familles : Catppuccin, Nord, Gruvbox (Retro), Tokyo Night et les thèmes exclusifs Psysonic (Classic Winamp, Poison, Nucleo, Psychowave).', + q13: 'Comment changer la langue ?', + a13: 'Paramètres → Langue. L\'anglais, l\'allemand, le français et le néerlandais sont pris en charge.', + q14: 'Que fait « Réduire dans la barre système » ?', + a14: 'Quand activé, cliquer sur × masque Psysonic dans la barre système au lieu de le fermer. La musique continue de jouer. Cliquez droit sur l\'icône pour accéder aux contrôles ou quitter.', + q15: 'Comment définir un dossier de téléchargement ?', + a15: 'Paramètres → Comportement → Dossier de téléchargement. Les albums téléchargés y sont enregistrés en ZIP.', + s5: 'Scrobbling', + q16: 'Comment fonctionne le scrobbling ?', + a16: 'Psysonic envoie les scrobbles directement à Last.fm — aucune configuration Navidrome nécessaire. Connectez votre compte dans Paramètres → Last.fm et activez le scrobbling.', + q17: 'Quand un scrobble est-il envoyé ?', + a17: 'Un scrobble est soumis après avoir écouté 50% d\'une piste.', + q22: 'Qu\'est-ce que la forme d\'onde dans la barre du lecteur ?', + a22: 'La barre de forme d\'onde remplace le curseur de progression classique. Cliquez n\'importe où pour sauter à cette position, ou faites glisser pour naviguer.', + q23: 'Comment utiliser le visualiseur MilkDrop ?', + a23: 'Ouvrez le lecteur plein écran (cliquez sur la pochette), puis cliquez sur l\'icône de forme d\'onde en haut à droite. Utilisez le bouton aléatoire pour parcourir les préréglages.', + q24: 'Puis-je mélanger la file d\'attente ?', + a24: 'Oui. Ouvrez le panneau de file et cliquez sur l\'icône de mélange. La piste en cours reste en position 1 — les autres sont réorganisées aléatoirement.', + q25: 'Les liens Last.fm et Wikipédia s\'ouvrent-ils dans un navigateur ?', + a25: 'Oui — ils s\'ouvrent dans votre navigateur système par défaut. Le bouton affiche brièvement une confirmation au clic.', + s7: 'Mix aléatoire', + q26: 'Qu\'est-ce que le Mix aléatoire ?', + a26: 'Le Mix aléatoire crée une liste de morceaux aléatoires depuis toute votre bibliothèque. Ouvrez-le via « Mix aléatoire » dans la barre latérale et cliquez sur « Nouveau mix ».', + q27: 'Qu\'est-ce que le Filtre par mot-clé ?', + a27: 'Le Filtre par mot-clé exclut les pistes dont le genre, le titre ou l\'album contient des mots spécifiques. Les livres audio sont filtrés automatiquement. Ajoutez des mots-clés dans Paramètres → Mix aléatoire.', + q28: 'Qu\'est-ce que le Mix par super-genre ?', + a28: 'Le Mix par super-genre regroupe votre bibliothèque en grandes catégories (Rock, Métal, Électronique, Jazz, Classique, etc.) et crée un mix ciblé. Sélectionnez une catégorie dans les puces sous la liste.', + s6: 'Dépannage', + q18: 'Les pochettes et images d\'artistes chargent lentement.', + a18: 'Les images sont récupérées depuis le disque de votre serveur au premier chargement, puis mises en cache localement pendant 30 jours. Les visites suivantes seront instantanées.', + q19: 'Le test de connexion échoue.', + a19: 'Vérifiez l\'URL avec le port (ex. http://192.168.1.100:4533). Assurez-vous qu\'aucun pare-feu ne bloque la connexion. Essayez http:// au lieu de https:// sur un réseau local.', + q20: 'Pas d\'audio sur Linux.', + a20: 'Psysonic est disponible en .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) et via AUR sur Arch/CachyOS (yay -S psysonic). Il n\'y a pas d\'AppImage. Si l\'audio ne fonctionne pas, vérifiez que PipeWire ou PulseAudio est actif.', + q21: 'L\'application affiche un écran noir sur Linux.', + a21: 'Cela est généralement dû à des problèmes de pilote GPU/EGL dans WebKitGTK. Lancez avec GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Les paquets AUR et installateurs .deb/.rpm officiels le configurent automatiquement.', + q29: 'Que sont le fondu enchaîné et la lecture sans blanc ?', + a29: 'Ce sont des fonctionnalités audio expérimentales dans Paramètres → Audio. La lecture sans blanc pré-charge la piste suivante pour éliminer les silences. Le fondu enchaîné effectue un fondu sortant de la piste en cours tout en faisant un fondu entrant de la suivante — durée réglable (1–10 s).', + }, + queue: { + title: 'File d\'attente', + savePlaylist: 'Enregistrer la liste', + playlistName: 'Nom de la liste', + cancel: 'Annuler', + save: 'Enregistrer', + loadPlaylist: 'Charger une liste', + loading: 'Chargement…', + noPlaylists: 'Aucune liste trouvée.', + load: 'Charger', + delete: 'Supprimer', + deleteConfirm: 'Supprimer la liste « {{name}} » ?', + clear: 'Vider', + shuffle: 'Mélanger la file', + gapless: 'Sans blanc', + crossfade: 'Fondu', + hide: 'Masquer', + close: 'Fermer', + nextTracks: 'Pistes suivantes', + emptyQueue: 'La file d\'attente est vide.', + trackSingular: 'piste', + trackPlural: 'pistes', + }, + statistics: { + title: 'Statistiques', + recentlyPlayed: 'Écoutés récemment', + mostPlayed: 'Albums les plus écoutés', + highestRated: 'Albums les mieux notés', + genreDistribution: 'Répartition par genre (Top 20)', + loadMore: 'Charger plus', + statArtists: 'Artistes', + statAlbums: 'Albums', + statSongs: 'Morceaux', + statGenres: 'Genres', + genreSongs: '{{count}} morceaux', + genreAlbums: '{{count}} albums', + recentlyAdded: 'Ajoutés récemment', + decadeDistribution: 'Albums par décennie', + decadeAlbums_one: '{{count}} album', + decadeAlbums_other: '{{count}} albums', + decadeUnknown: 'Inconnu', + lfmTitle: 'Stats Last.fm', + lfmTopArtists: 'Artistes favoris', + lfmTopAlbums: 'Albums favoris', + lfmTopTracks: 'Morceaux favoris', + lfmPlays: '{{count}} écoutes', + lfmPeriodOverall: 'Tout le temps', + lfmPeriod7day: '7 jours', + lfmPeriod1month: '1 mois', + lfmPeriod3month: '3 mois', + lfmPeriod6month: '6 mois', + lfmPeriod12month: '12 mois', + lfmNotConnected: 'Connectez Last.fm dans les Paramètres pour voir vos stats.', + lfmRecentTracks: 'Scrobbles récents', + lfmNowPlaying: 'En cours de lecture', + lfmJustNow: 'à l\'instant', + lfmMinutesAgo: 'il y a {{n}} min', + lfmHoursAgo: 'il y a {{n}}h', + lfmDaysAgo: 'il y a {{n}}j', + }, + player: { + regionLabel: 'Lecteur de musique', + openFullscreen: 'Ouvrir le lecteur plein écran', + fullscreen: 'Lecteur plein écran', + closeFullscreen: 'Fermer le plein écran', + visualizer: 'Visualiseur', + nextPreset: 'Préréglage suivant', + closeTooltip: 'Fermer (Échap)', + noTitle: 'Sans titre', + stop: 'Arrêter', + prev: 'Piste précédente', + play: 'Lecture', + pause: 'Pause', + next: 'Piste suivante', + repeat: 'Répéter', + repeatOff: 'Désactivé', + repeatAll: 'Tout', + repeatOne: 'Un', + progress: 'Progression', + volume: 'Volume', + toggleQueue: 'Afficher/masquer la file' + } +}; + +// Dutch Translations +const nlTranslation = { + sidebar: { + library: 'Bibliotheek', + mainstage: 'Hoofdpodium', + newReleases: 'Nieuw', + allAlbums: 'Alle albums', + randomAlbums: 'Willekeurige albums', + artists: 'Artiesten', + playlists: 'Afspeellijsten', + randomMix: 'Willekeurige mix', + favorites: 'Favorieten', + nowPlaying: 'Nu bezig', + system: 'Systeem', + statistics: 'Statistieken', + settings: 'Instellingen', + help: 'Help', + expand: 'Zijbalk uitklappen', + collapse: 'Zijbalk inklappen', + updateAvailable: 'Update beschikbaar', + updateReady: '{{version}} is klaar', + updateLink: 'Naar release →' + }, + home: { + starred: 'Persoonlijke favorieten', + recent: 'Recent toegevoegd', + mostPlayed: 'Meest gespeeld', + discover: 'Ontdekken', + loadMore: 'Meer laden', + discoverMore: 'Meer ontdekken' + }, + hero: { + eyebrow: 'Uitgelicht album', + playAlbum: 'Album afspelen', + enqueue: 'In wachtrij', + enqueueTooltip: 'Volledig album aan wachtrij toevoegen', + }, + search: { + placeholder: 'Zoek naar artiest, album of nummer…', + noResults: 'Geen resultaten voor "{{query}}"', + artists: 'Artiesten', + albums: 'Albums', + songs: 'Nummers', + clearLabel: 'Zoekopdracht wissen', + title: 'Zoeken', + resultsFor: 'Resultaten voor "{{query}}"', + album: 'Album', + }, + nowPlaying: { + tooltip: 'Wie luistert er?', + title: 'Wie luistert er?', + loading: 'Laden…', + nobody: 'Er luistert momenteel niemand.', + minutesAgo: '{{n}} min geleden', + nothingPlaying: 'Nog niets bezig. Start een nummer!', + }, + contextMenu: { + playNow: 'Nu afspelen', + playNext: 'Volgende afspelen', + addToQueue: 'Aan wachtrij toevoegen', + enqueueAlbum: 'Album in wachtrij', + startRadio: 'Radio starten', + lfmLove: 'Liken op Last.fm', + lfmUnlove: 'Niet meer liken op Last.fm', + favorite: 'Favoriet', + favoriteArtist: 'Favoriete artiest', + favoriteAlbum: 'Favoriet album', + removeFromQueue: 'Uit wachtrij verwijderen', + openAlbum: 'Album openen', + goToArtist: 'Naar artiest', + download: 'Downloaden (ZIP)', + }, + albumDetail: { + back: 'Terug', + playAll: 'Alles afspelen', + enqueue: 'In wachtrij', + enqueueTooltip: 'Volledig album aan wachtrij toevoegen', + artistBio: 'Artiest biografie', + download: 'Downloaden (ZIP)', + downloading: 'Laden…', + downloadHint: 'FLAC/WAV-albums worden eerst ingepakt door de server — grote albums kunnen even duren voordat de download begint.', + downloadHintShort: 'Server pakt eerst in — kan even duren afhankelijk van bestandsgrootte', + favoriteAdd: 'Aan favorieten toevoegen', + favoriteRemove: 'Uit favorieten verwijderen', + favorite: 'Favoriet', + noBio: 'Geen biografie beschikbaar.', + moreByArtist: 'Meer van {{artist}}', + tracksCount: '{{n}} nummers', + goToArtist: 'Naar {{artist}}', + moreLabelAlbums: 'Meer albums op {{label}}', + trackTitle: 'Titel', + trackArtist: 'Artiest', + trackFormat: 'Formaat', + trackFavorite: 'Favoriet', + trackRating: 'Beoordeling', + trackDuration: 'Duur', + trackTotal: 'Totaal', + notFound: 'Album niet gevonden.', + bioModal: 'Artiest biografie', + bioClose: 'Sluiten', + ratingLabel: 'Beoordeling', + }, + artistDetail: { + back: 'Terug', + albums: 'Albums', + album: 'Album', + playAll: 'Alles afspelen', + shuffle: 'Willekeurig', + radio: 'Radio', + loading: 'Laden…', + noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.', + notFound: 'Artiest niet gevonden.', + albumsBy: 'Albums van {{name}}', + topTracks: 'Populaire nummers', + noAlbums: 'Geen albums gevonden.', + trackTitle: 'Titel', + trackAlbum: 'Album', + trackDuration: 'Duur', + favoriteAdd: 'Aan favorieten toevoegen', + favoriteRemove: 'Uit favorieten verwijderen', + favorite: 'Favoriet', + albumCount_one: '{{count}} album', + albumCount_other: '{{count}} albums', + openedInBrowser: 'Geopend in browser', + featuredOn: 'Ook te vinden op', + similarArtists: 'Vergelijkbare artiesten', + }, + favorites: { + title: 'Favorieten', + empty: 'Je hebt nog geen favorieten opgeslagen.', + artists: 'Artiesten', + albums: 'Albums', + songs: 'Nummers', + enqueueAll: 'Alles aan wachtrij toevoegen', + }, + randomAlbums: { + title: 'Willekeurige albums', + refresh: 'Vernieuwen', + }, + randomMix: { + title: 'Willekeurige mix', + remix: 'Opnieuw mixen', + remixTooltip: 'Nieuwe willekeurige nummers laden', + playAll: 'Alles afspelen', + trackTitle: 'Titel', + trackArtist: 'Artiest', + trackAlbum: 'Album', + trackFavorite: 'Favoriet', + trackDuration: 'Duur', + favoriteAdd: 'Aan favorieten toevoegen', + favoriteRemove: 'Uit favorieten verwijderen', + play: 'Afspelen', + trackGenre: 'Genre', + excludeAudiobooks: 'Luisterboeken en hoorspelen uitsluiten', + excludeAudiobooksDesc: 'Vergelijkt trefwoorden met genre, titel en album — bijv. Hörbuch, Audiobook, Spoken Word, …', + genreBlocked: 'Trefwoord geblokkeerd', + genreAddedToBlacklist: 'Aan filterlijst toegevoegd', + genreAlreadyBlocked: 'Al geblokkeerd', + blacklistToggle: 'Trefwoordfilter', + genreMixTitle: 'Genremix', + genreMixDesc: 'Selecteer een genre voor een samengestelde willekeurige mix', + genreMixLoadMore: '10 meer laden', + genreMixNoGenres: 'Geen genres gevonden op server.', + filterPanelTitle: 'Filters', + genreClickHint: 'Klik op een genre-tag om het\ntoe te voegen als filtertrefwoord.\nVergelijkt genre, titel & album.', + }, + playlists: { + title: 'Afspeellijsten', + loading: 'Afspeellijsten laden…', + empty: 'Geen afspeellijsten gevonden.\nGebruik de wachtrij om afspeellijsten aan te maken.', + play: 'Afspelen', + deleteTooltip: 'Verwijderen', + confirmDelete: 'Afspeellijst "{{name}}" echt verwijderen?', + minutes: 'min.', + track_one: '{{count}} nummer', + track_other: '{{count}} nummers', + filterPlaceholder: 'Afspeellijsten filteren…', + colName: 'Naam', + colTracks: 'Nummers', + colDuration: 'Duur', + noResults: 'Geen afspeellijsten komen overeen met het filter.', + }, + albums: { + title: 'Alle albums', + sortByName: 'A–Z (Album)', + sortByArtist: 'A–Z (Artiest)', + sortNewest: 'Nieuwste eerst', + sortRandom: 'Willekeurig', + }, + artists: { + title: 'Artiesten', + search: 'Zoeken…', + all: 'Alle', + gridView: 'Rasterweergave', + listView: 'Lijstweergave', + loadMore: 'Meer laden', + notFound: 'Geen artiesten gevonden.', + albumCount_one: '{{count}} album', + albumCount_other: '{{count}} albums', + }, + login: { + subtitle: 'Jouw Navidrome-desktopspeler', + serverName: 'Servernaam (optioneel)', + serverNamePlaceholder: 'Mijn Navidrome', + serverUrl: 'Server-URL', + serverUrlPlaceholder: '192.168.1.100:4533 of music.voorbeeld.nl', + username: 'Gebruikersnaam', + usernamePlaceholder: 'admin', + password: 'Wachtwoord', + showPassword: 'Wachtwoord tonen', + hidePassword: 'Wachtwoord verbergen', + connect: 'Verbinden', + connecting: 'Verbinden…', + connected: 'Verbonden!', + error: 'Verbinding mislukt — controleer je gegevens.', + urlRequired: 'Voer een server-URL in.', + savedServers: 'Opgeslagen servers', + addNew: 'Of een nieuwe server toevoegen', + }, + connection: { + connected: 'Verbonden', + connectedTo: 'Verbonden met {{server}}', + disconnected: 'Verbroken', + disconnectedFrom: 'Kan {{server}} niet bereiken — klik om instellingen te controleren', + checking: 'Verbinden…', + extern: 'Extern', + offlineTitle: 'Geen serververbinding', + offlineSubtitle: 'Kan {{server}} niet bereiken. Controleer je netwerk of server.', + retry: 'Opnieuw proberen', + lastfmConnected: 'Last.fm verbonden als @{{user}}', + lastfmSessionInvalid: 'Sessie ongeldig — klik om opnieuw te verbinden', + }, + common: { + albums: 'Albums', + album: 'Album', + loading: 'Laden…', + loadingMore: 'Laden…', + loadingPlaylists: 'Afspeellijsten laden…', + noAlbums: 'Geen albums gevonden.', + downloading: 'Downloaden…', + downloadZip: 'Downloaden (ZIP)', + back: 'Terug', + cancel: 'Annuleren', + save: 'Opslaan', + delete: 'Verwijderen', + use: 'Gebruiken', + add: 'Toevoegen', + active: 'Actief', + download: 'Downloaden', + chooseDownloadFolder: 'Downloadmap kiezen', + noFolderSelected: 'Geen map geselecteerd', + rememberDownloadFolder: 'Deze map onthouden', + }, + settings: { + title: 'Instellingen', + language: 'Taal', + languageEn: 'Engels', + languageDe: 'Duits', + languageFr: 'Frans', + languageNl: 'Nederlands', + theme: 'Thema', + appearance: 'Weergave', + servers: 'Servers', + serverName: 'Servernaam', + serverUrl: 'Server-URL', + serverUsername: 'Gebruikersnaam', + serverPassword: 'Wachtwoord', + addServer: 'Server toevoegen', + addServerTitle: 'Nieuwe server toevoegen', + useServer: 'Gebruiken', + deleteServer: 'Verwijderen', + noServers: 'Geen servers opgeslagen.', + serverActive: 'Actief', + confirmDeleteServer: 'Server "{{name}}" verwijderen?', + serverConnecting: 'Verbinden…', + serverConnected: 'Verbonden!', + serverFailed: 'Verbinding mislukt.', + testBtn: 'Verbinding testen', + testingBtn: 'Testen…', + connected: 'Verbonden', + failed: 'Mislukt', + eqTitle: 'Equalizer', + eqEnabled: 'Equalizer inschakelen', + eqPreset: 'Voorinstelling', + eqPresetCustom: 'Aangepast', + eqPresetBuiltin: 'Ingebouwde voorinstellingen', + eqPresetCustomGroup: 'Mijn voorinstellingen', + eqSavePreset: 'Opslaan als voorinstelling', + eqPresetName: 'Naam voorinstelling…', + eqDeletePreset: 'Voorinstelling verwijderen', + eqResetBands: 'Terugzetten naar vlak', + lfmTitle: 'Last.fm', + lfmConnect: 'Verbinden met Last.fm', + lfmConnecting: 'Wachten op autorisatie…', + lfmConfirm: 'Ik heb de app geautoriseerd', + lfmConnected: 'Verbonden als', + lfmDisconnect: 'Verbinding verbreken', + lfmConnectDesc: 'Verbind je Last.fm-account om scrobbling en Nu bezig-updates rechtstreeks vanuit Psysonic in te schakelen — geen Navidrome-configuratie vereist.', + lfmOpenBrowser: 'Er opent een browservenster. Autoriseer Psysonic op Last.fm en klik daarna op de knop hieronder.', + lfmScrobbles: '{{n}} scrobbles', + lfmMemberSince: 'Lid sinds {{year}}', + scrobbleEnabled: 'Scrobbling ingeschakeld', + scrobbleDesc: 'Nummers naar Last.fm sturen na 50% afspeeltijd', + behavior: 'App-gedrag', + trayTitle: 'Minimaliseren naar systeemvak', + trayDesc: 'App naar het systeemvak minimaliseren bij sluiten (X)', + cacheTitle: 'Max. cachegrootte', + cacheDesc: 'Voor vooraf geladen nummers', + downloadsTitle: 'Downloadmap', + downloadsDefault: 'Standaard downloadmap', + pickFolder: 'Selecteren', + pickFolderTitle: 'Downloadmap selecteren', + clearFolder: 'Map wissen', + logout: 'Uitloggen', + aboutTitle: 'Over Psysonic', + aboutDesc: 'Een desktopmuziekspeeler voor Subsonic-compatibele servers (Navidrome, Gonic en anderen). Streamt je zelf gehoste muziekbibliotheek met een strakke, moderne interface.', + aboutFeatures: 'Multi-server · Scrobbling · Volledig scherm · Album downloads · Afbeeldingscache · Catppuccin-thema\'s', + aboutLicense: 'Licentie', + aboutLicenseText: 'GNU GPL v3 — vrij te gebruiken, wijzigen en verspreiden onder dezelfde licentie.', + aboutRepo: 'Broncode op GitHub', + aboutVersion: 'Versie', + aboutBuiltWith: 'Gebouwd met Tauri · React · TypeScript · Rust/rodio', + aboutAiCredit: 'Ontwikkeld met ondersteuning van Claude Code door Anthropic', + changelog: 'Wijzigingslog', + randomMixTitle: 'Willekeurige mix', + randomMixBlacklistTitle: 'Aangepaste filtertrefwoorden', + randomMixBlacklistDesc: 'Nummers worden uitgesloten als een trefwoord overeenkomt met hun genre, titel of album (actief wanneer het selectievakje hierboven is aangevinkt).', + randomMixBlacklistPlaceholder: 'Trefwoord toevoegen…', + randomMixBlacklistAdd: 'Toevoegen', + randomMixBlacklistEmpty: 'Nog geen aangepaste trefwoorden toegevoegd.', + randomMixHardcodedTitle: 'Ingebouwde trefwoorden (actief wanneer selectievakje is aangevinkt)', + tabPlayback: 'Afspelen', + tabLibrary: 'Bibliotheek', + tabAppearance: 'Weergave', + tabServer: 'Server', + tabAbout: 'Over', + playbackTitle: 'Afspelen', + replayGain: 'Replay Gain', + replayGainDesc: 'Nummervolume normaliseren met ReplayGain-metadata', + replayGainMode: 'Modus', + replayGainTrack: 'Nummer', + replayGainAlbum: 'Album', + crossfade: 'Overgang', + crossfadeDesc: 'Fade tussen nummers', + crossfadeSecs: '{{n}}s', + gapless: 'Naadloos afspelen', + gaplessDesc: 'Volgend nummer vooraf bufferen om stiltes tussen nummers te elimineren', + experimental: 'Experimenteel', + }, + help: { + title: 'Help', + s1: 'Aan de slag', + q1: 'Welke servers zijn compatibel?', + a1: 'Psysonic werkt met elke Subsonic-compatibele server: Navidrome, Gonic, Subsonic, Airsonic en anderen. Navidrome is de aanbevolen keuze.', + q2: 'Hoe verbind ik me met mijn server?', + a2: 'Open Instellingen en klik op "Server toevoegen". Voer de server-URL in (bijv. 192.168.1.100:4533), je gebruikersnaam en wachtwoord. Psysonic test de verbinding voor het opslaan.', + q3: 'Kan ik meerdere servers gebruiken?', + a3: 'Ja. Je kunt zoveel servers toevoegen als je wilt in Instellingen en er op elk moment tussen wisselen. Slechts één server is tegelijk actief.', + s2: 'Afspelen', + q4: 'Hoe speel ik muziek af?', + a4: 'Dubbelklik op een nummer om het af te spelen. Gebruik op album- en artiestpagina\'s "Alles afspelen" om het hele album te starten. Je kunt ook nummers naar de wachtrij slepen.', + q5: 'Welke sneltoetsen zijn beschikbaar?', + a5: 'Spatie = Afspelen / Pauzeren · Escape = Volledig scherm sluiten. Mediatoetsen werken op macOS en Windows. Gebruik op Linux de knoppen van de spelerbalk.', + q6: 'Wat is de wachtrij?', + a6: 'De wachtrij toont alle komende nummers. Open deze met het paneelpictogram rechtsboven. Je kunt nummers herordenen door te slepen, shufflen en de wachtrij opslaan als afspeellijst.', + q7: 'Hoe open ik de volledig-schermspeler?', + a7: 'Klik op de albumhoes in de spelerbalk onderaan, of het uitvouwpictogram ernaast. Druk op Escape om te sluiten.', + q8: 'Hoe werkt herhalen?', + a8: 'Klik op de herhalknop om te wisselen: Uit → Alles herhalen → Één herhalen.', + s3: 'Bibliotheek', + q9: 'Hoe download ik een album?', + a9: 'Open de detailpagina van een album en klik op "Downloaden (ZIP)". De server pakt het album eerst in — dit kan even duren voor grote albums of verliesvrije bestanden.', + q10: 'Hoe markeer ik nummers en albums als favoriet?', + a10: 'Klik op het sterpictogram op een nummervak of de albumkoptekst. Gemarkeerde items verschijnen in de sectie Favorieten in de zijbalk.', + q11: 'Wat is de hero-carrousel op de startpagina?', + a11: 'De banner bovenaan de startpagina kiest willekeurig albums uit je bibliotheek en wisselt deze elke 10 seconden af. Klik op de puntjes om naar een specifiek album te springen.', + s4: 'Instellingen', + q12: 'Hoe verander ik het thema?', + a12: 'Instellingen → Thema. Meer dan 20 thema\'s in 6 families: Catppuccin, Nord, Gruvbox (Retro), Tokyo Night en de exclusieve Psysonic Themes (Classic Winamp, Poison, Nucleo, Psychowave).', + q13: 'Hoe verander ik de taal?', + a13: 'Instellingen → Taal. Engels, Duits, Frans en Nederlands worden momenteel ondersteund.', + q14: 'Wat doet "Minimaliseren naar systeemvak"?', + a14: 'Wanneer ingeschakeld verbergt het klikken op × Psysonic naar het systeemvak in plaats van het te sluiten. Muziek blijft spelen. Klik rechts op het pictogram voor besturingen of om af te sluiten.', + q15: 'Hoe stel ik een downloadmap in?', + a15: 'Instellingen → App-gedrag → Downloadmap. Gedownloade albums worden daar opgeslagen als ZIP-bestanden.', + s5: 'Scrobbling', + q16: 'Hoe werkt scrobbling?', + a16: 'Psysonic scrobbelt rechtstreeks naar Last.fm — geen Navidrome-configuratie nodig. Verbind je Last.fm-account in Instellingen → Last.fm en schakel scrobbling in.', + q17: 'Wanneer wordt een scrobble verstuurd?', + a17: 'Een scrobble wordt verstuurd nadat je 50% van een nummer hebt geluisterd.', + q22: 'Wat is de golfvorm in de spelerbalk?', + a22: 'De golfvormzoekbalk vervangt de klassieke voortgangsschuifregelaar. Klik ergens om naar die positie te springen, of sleep om door het nummer te navigeren.', + q23: 'Hoe gebruik ik de MilkDrop-visualizer?', + a23: 'Open de volledig-schermspeler (klik op de albumhoes) en klik dan op het golfvormpictogram rechtsboven. Gebruik de willekeurigknop om door honderden presets te bladeren.', + q24: 'Kan ik de wachtrij shufflen?', + a24: 'Ja. Open het wachtrijpaneel en klik op het shufflepictogram. Het huidige nummer blijft op positie 1 — alle overige nummers worden willekeurig geherordend.', + q25: 'Openen Last.fm- en Wikipedia-links in een browser?', + a25: 'Ja — ze openen in je standaard systeembrowser. De knop toont kort een bevestiging bij klikken.', + s7: 'Willekeurige mix', + q26: 'Wat is de Willekeurige mix?', + a26: 'De Willekeurige mix bouwt een afspeellijst van willekeurige nummers uit je hele bibliotheek. Open het via "Willekeurige mix" in de zijbalk en klik op "Nieuwe mix".', + q27: 'Wat is het Trefwoordfilter?', + a27: 'Het Trefwoordfilter sluit nummers uit waarvan het genre, de titel of de albumnaam bepaalde woorden bevat. Luisterboeken worden automatisch gefilterd. Voeg aangepaste trefwoorden toe in Instellingen → Willekeurige mix.', + q28: 'Wat is de Super-genremix?', + a28: 'De Super-genremix groepeert je bibliotheek in brede categorieën (Rock, Metal, Elektronisch, Jazz, Klassiek, enz.) en bouwt een gerichte mix van die stijl. Selecteer een categoriechip onder de lijst.', + s6: 'Probleemoplossing', + q18: 'Albumhoezen en artiestenafbeeldingen laden langzaam.', + a18: 'Afbeeldingen worden bij het eerste laden van de schijf van je server opgehaald en vervolgens 30 dagen lokaal gecachet. Volgende bezoeken zijn direct.', + q19: 'De verbindingstest mislukt.', + a19: 'Controleer de URL inclusief de poort (bijv. http://192.168.1.100:4533). Zorg dat geen firewall de verbinding blokkeert. Probeer http:// in plaats van https:// op een lokaal netwerk.', + q20: 'Geen audio op Linux.', + a20: 'Psysonic is beschikbaar als .deb (Ubuntu/Debian), .rpm (Fedora/RHEL) en via AUR voor Arch/CachyOS (yay -S psysonic). Er is geen AppImage. Controleer of PipeWire of PulseAudio actief is als er geen audio is.', + q21: 'De app toont een zwart scherm op Linux.', + a21: 'Dit is meestal een GPU/EGL-driverprobleem in WebKitGTK. Start met GDK_BACKEND=x11 WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1. Het AUR-pakket en officiële .deb/.rpm-installatieprogramma\'s doen dit automatisch.', + q29: 'Wat zijn overgang (crossfade) en naadloos afspelen?', + a29: 'Dit zijn experimentele audiofuncties in Instellingen → Audio. Naadloos afspelen buffert het volgende nummer voor om stiltes te elimineren. Overgang fadeert het huidige nummer uit terwijl het volgende infadeert — duur (1–10 s) instelbaar.', + }, + queue: { + title: 'Wachtrij', + savePlaylist: 'Afspeellijst opslaan', + playlistName: 'Naam afspeellijst', + cancel: 'Annuleren', + save: 'Opslaan', + loadPlaylist: 'Afspeellijst laden', + loading: 'Laden…', + noPlaylists: 'Geen afspeellijsten gevonden.', + load: 'Laden', + delete: 'Verwijderen', + deleteConfirm: 'Afspeellijst "{{name}}" verwijderen?', + clear: 'Leegmaken', + shuffle: 'Wachtrij shufflen', + gapless: 'Naadloos', + crossfade: 'Overgang', + hide: 'Verbergen', + close: 'Sluiten', + nextTracks: 'Volgende nummers', + emptyQueue: 'De wachtrij is leeg.', + trackSingular: 'nummer', + trackPlural: 'nummers', + }, + statistics: { + title: 'Statistieken', + recentlyPlayed: 'Recent afgespeeld', + mostPlayed: 'Meest gespeelde albums', + highestRated: 'Best beoordeelde albums', + genreDistribution: 'Genreverdeling (Top 20)', + loadMore: 'Meer laden', + statArtists: 'Artiesten', + statAlbums: 'Albums', + statSongs: 'Nummers', + statGenres: 'Genres', + genreSongs: '{{count}} nummers', + genreAlbums: '{{count}} albums', + recentlyAdded: 'Recent toegevoegd', + decadeDistribution: 'Albums per decennium', + decadeAlbums_one: '{{count}} album', + decadeAlbums_other: '{{count}} albums', + decadeUnknown: 'Onbekend', + lfmTitle: 'Last.fm-statistieken', + lfmTopArtists: 'Topartiesten', + lfmTopAlbums: 'Topalbums', + lfmTopTracks: 'Topnummers', + lfmPlays: '{{count}} keer gespeeld', + lfmPeriodOverall: 'Alle tijd', + lfmPeriod7day: '7 dagen', + lfmPeriod1month: '1 maand', + lfmPeriod3month: '3 maanden', + lfmPeriod6month: '6 maanden', + lfmPeriod12month: '12 maanden', + lfmNotConnected: 'Verbind Last.fm in Instellingen om je statistieken te zien.', + lfmRecentTracks: 'Recente scrobbles', + lfmNowPlaying: 'Nu bezig', + lfmJustNow: 'zojuist', + lfmMinutesAgo: '{{n}} min geleden', + lfmHoursAgo: '{{n}} uur geleden', + lfmDaysAgo: '{{n}} dagen geleden', + }, + player: { + regionLabel: 'Muziekspeler', + openFullscreen: 'Volledig-schermspeler openen', + fullscreen: 'Volledig-schermspeler', + closeFullscreen: 'Volledig scherm sluiten', + visualizer: 'Visualizer', + nextPreset: 'Volgende preset', + closeTooltip: 'Sluiten (Escape)', + noTitle: 'Geen titel', + stop: 'Stoppen', + prev: 'Vorig nummer', + play: 'Afspelen', + pause: 'Pauzeren', + next: 'Volgend nummer', + repeat: 'Herhalen', + repeatOff: 'Uit', + repeatAll: 'Alles', + repeatOne: 'Één', + progress: 'Nummervoortgang', + volume: 'Volume', + toggleQueue: 'Wachtrij in-/uitschakelen' + } +}; + // Retrieve language from local storage or default to 'en' const savedLanguage = localStorage.getItem('psysonic_language') || 'en'; @@ -989,7 +2013,9 @@ i18n .init({ resources: { en: { translation: enTranslation }, - de: { translation: deTranslation } + de: { translation: deTranslation }, + fr: { translation: frTranslation }, + nl: { translation: nlTranslation } }, lng: savedLanguage, fallbackLng: 'en', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 229f565e..717a1333 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; +import CoverLightbox from '../components/CoverLightbox'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; import { open } from '@tauri-apps/plugin-shell'; import { usePlayerStore } from '../store/playerStore'; @@ -49,6 +50,8 @@ export default function ArtistDetail() { const [openedLink, setOpenedLink] = useState(null); const [similarArtists, setSimilarArtists] = useState([]); const [similarLoading, setSimilarLoading] = useState(false); + const [featuredLoading, setFeaturedLoading] = useState(false); + const [lightboxOpen, setLightboxOpen] = useState(false); const playTrack = usePlayerStore(state => state.playTrack); const enqueue = usePlayerStore(state => state.enqueue); @@ -58,45 +61,18 @@ export default function ArtistDetail() { useEffect(() => { if (!id) return; setLoading(true); - let ownAlbumIds: Set; + setFeaturedAlbums([]); getArtist(id).then(artistData => { setArtist(artistData.artist); setAlbums(artistData.albums); setIsStarred(!!artistData.artist.starred); - ownAlbumIds = new Set(artistData.albums.map(a => a.id)); return Promise.all([ getArtistInfo(id).catch(() => null), getTopSongs(artistData.artist.name).catch(() => []), - search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })), ]); - }).then(([artistInfo, songsData, searchResults]) => { + }).then(([artistInfo, songsData]) => { if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null); if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]); - - const featuredSongs = (searchResults.songs ?? []).filter( - song => song.artistId === id && !ownAlbumIds.has(song.albumId) - ); - const albumMap = new Map(); - 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); }).catch(err => { console.error(err); @@ -104,6 +80,41 @@ export default function ArtistDetail() { }); }, [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(); + 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(() => { if (!artist || !lastfmIsConfigured()) return; setSimilarArtists([]); @@ -213,16 +224,30 @@ export default function ArtistDetail() { {t('artistDetail.back')} + {lightboxOpen && ( + setLightboxOpen(false)} + /> + )} +
{coverId ? ( - { (e.currentTarget as HTMLImageElement).style.display = 'none'; }} - /> + ) : ( )} @@ -389,14 +414,22 @@ export default function ArtistDetail() { )} {/* Also Featured On */} - {featuredAlbums.length > 0 && ( + {(featuredLoading || featuredAlbums.length > 0) && ( <>

{t('artistDetail.featuredOn')}

-
- {featuredAlbums.map(a => )} -
+ {featuredLoading ? ( +
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+ ) : ( +
+ {featuredAlbums.map(a => )} +
+ )} )}
diff --git a/src/pages/Help.tsx b/src/pages/Help.tsx index 9e7d3bdf..7f48310b 100644 --- a/src/pages/Help.tsx +++ b/src/pages/Help.tsx @@ -45,6 +45,7 @@ export default function Help() { { q: t('help.q22'), a: t('help.a22') }, { q: t('help.q23'), a: t('help.a23') }, { q: t('help.q24'), a: t('help.a24') }, + { q: t('help.q29'), a: t('help.a29') }, ], }, { @@ -100,7 +101,7 @@ export default function Help() {

{t('help.title')}

-
+
{sections.map((section, si) => (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 1e2a943a..fe22f9be 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,15 +1,16 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react'; import { version as appVersion } from '../../package.json'; import changelogRaw from '../../CHANGELOG.md?raw'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play } from 'lucide-react'; import { open as openUrl } from '@tauri-apps/plugin-shell'; -import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmIsConfigured, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; +import { lastfmGetToken, lastfmAuthUrl, lastfmGetSession, lastfmGetUserInfo, LastfmUserInfo } from '../api/lastfm'; import LastfmIcon from '../components/LastfmIcon'; import CustomSelect from '../components/CustomSelect'; +import ThemePicker from '../components/ThemePicker'; import { useAuthStore, ServerProfile } from '../store/authStore'; import { useThemeStore } from '../store/themeStore'; import { pingWithCredentials } from '../api/subsonic'; @@ -83,9 +84,10 @@ export default function Settings() { const auth = useAuthStore(); const theme = useThemeStore(); const navigate = useNavigate(); + const { state: routeState } = useLocation(); const { t, i18n } = useTranslation(); - const [activeTab, setActiveTab] = useState('playback'); + const [activeTab, setActiveTab] = useState((routeState as { tab?: Tab } | null)?.tab ?? 'playback'); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); const [newGenre, setNewGenre] = useState(''); @@ -287,7 +289,12 @@ export default function Settings() { {/* Crossfade */}
-
{t('settings.crossfade')}
+
+ {t('settings.crossfade')} + + {t('settings.experimental')} + +
{t('settings.crossfadeDesc')}
- {/* Last.fm */} -
-
- -

{t('settings.lfmTitle')}

-
-
- {auth.lastfmSessionKey ? ( - /* ── Connected state ── */ -
-
-
-
-
@{auth.lastfmUsername}
- {lfmUserInfo && ( -
- {t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} - {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })} -
- )} -
- -
-
-
-
{t('settings.scrobbleEnabled')}
-
{t('settings.scrobbleDesc')}
-
- -
-
- ) : lfmState === 'waiting' ? ( - /* ── Waiting for browser auth — auto-polling ── */ -
-
-
- {t('settings.lfmConnecting')} -
- -
- ) : ( - /* ── Not connected ── */ -
-

- {t('settings.lfmConnectDesc')} -

- {lfmState === 'error' && ( -

{lfmError}

- )} - {lastfmIsConfigured() ? ( - - ) : ( -

- VITE_LASTFM_API_KEY / VITE_LASTFM_API_SECRET not set in build environment. -

- )} -
- )} -
-
)} @@ -530,23 +468,7 @@ export default function Settings() {

{t('settings.theme')}

-
- 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' }, - ]} - /> -
+ theme.setTheme(v as any)} />
@@ -561,7 +483,9 @@ export default function Settings() { value={i18n.language} onChange={v => i18n.changeLanguage(v)} options={[ + { value: 'nl', label: t('settings.languageNl') }, { value: 'en', label: t('settings.languageEn') }, + { value: 'fr', label: t('settings.languageFr') }, { value: 'de', label: t('settings.languageDe') }, ]} /> @@ -653,6 +577,75 @@ export default function Settings() { )} + {/* Last.fm */} +
+
+ +

{t('settings.lfmTitle')}

+
+
+ {auth.lastfmSessionKey ? ( + /* ── Connected state ── */ +
+
+
+
+
@{auth.lastfmUsername}
+ {lfmUserInfo && ( +
+ {t('settings.lfmScrobbles', { n: lfmUserInfo.playcount.toLocaleString() })} + {t('settings.lfmMemberSince', { year: new Date(lfmUserInfo.registeredAt * 1000).getFullYear() })} +
+ )} +
+ +
+
+
+
{t('settings.scrobbleEnabled')}
+
{t('settings.scrobbleDesc')}
+
+ +
+
+ ) : lfmState === 'waiting' ? ( + /* ── Waiting for browser auth — auto-polling ── */ +
+
+
+ {t('settings.lfmConnecting')} +
+ +
+ ) : ( + /* ── Not connected ── */ +
+

+ {t('settings.lfmConnectDesc')} +

+ {lfmState === 'error' && ( +

{lfmError}

+ )} + +
+ )} +
+
+ {/* Downloads + Tray */}
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 8efeca80..ddec4bcf 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -37,6 +37,7 @@ interface AuthState { isLoggedIn: boolean; isConnecting: boolean; connectionError: string | null; + lastfmSessionError: boolean; // Actions addServer: (profile: Omit) => string; @@ -49,6 +50,7 @@ interface AuthState { setLastfm: (apiKey: string, apiSecret: string, sessionKey: string, username: string) => void; connectLastfm: (sessionKey: string, username: string) => void; disconnectLastfm: () => void; + setLastfmSessionError: (v: boolean) => void; setMinimizeToTray: (v: boolean) => void; setScrobblingEnabled: (v: boolean) => void; setMaxCacheMb: (v: number) => void; @@ -94,6 +96,7 @@ export const useAuthStore = create()( isLoggedIn: false, isConnecting: false, connectionError: null, + lastfmSessionError: false, addServer: (profile) => { const id = generateId(); @@ -132,7 +135,9 @@ export const useAuthStore = create()( set({ lastfmSessionKey: sessionKey, lastfmUsername: username }), disconnectLastfm: () => - set({ lastfmSessionKey: '', lastfmUsername: '' }), + set({ lastfmSessionKey: '', lastfmUsername: '', lastfmSessionError: false }), + + setLastfmSessionError: (v) => set({ lastfmSessionError: v }), setMinimizeToTray: (v) => set({ minimizeToTray: v }), setScrobblingEnabled: (v) => set({ scrobblingEnabled: v }), diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 3d4452d8..156af7c4 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -160,9 +160,9 @@ function handleAudioProgress(current_time: number, duration: number) { } } - // Gapless preload: buffer next track when 30s remain + // Pre-buffer / pre-chain next track when 30 s remain. const { gaplessEnabled } = useAuthStore.getState(); - if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) { + if (dur - current_time < 30 && dur - current_time > 0) { const { queue, queueIndex, repeatMode } = store; const nextIdx = queueIndex + 1; const nextTrack = repeatMode === 'one' @@ -170,7 +170,30 @@ function handleAudioProgress(current_time: number, duration: number) { : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); if (nextTrack && nextTrack.id !== track.id) { const nextUrl = buildStreamUrl(nextTrack.id); - 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. usePlayerStore.getState().syncLastfmLovedTracks(); - // Initial sync of crossfade settings to Rust audio engine on startup. - const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState(); + // Initial sync of audio settings to Rust engine on startup. + const { crossfadeEnabled, crossfadeSecs, gaplessEnabled } = useAuthStore.getState(); invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); + invoke('audio_set_gapless', { enabled: gaplessEnabled }).catch(() => {}); - // Keep crossfade settings in sync whenever auth store changes. + // Keep audio settings in sync whenever auth store changes. const unsubAuth = useAuthStore.subscribe((state) => { invoke('audio_set_crossfade', { enabled: state.crossfadeEnabled, secs: state.crossfadeSecs, }).catch(() => {}); + invoke('audio_set_gapless', { enabled: state.gaplessEnabled }).catch(() => {}); }); return () => { diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index dac0eb80..1cbe7b2b 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave'; +type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'classic-winamp' | 'poison' | 'nucleo' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light'; interface ThemeState { theme: Theme; diff --git a/src/styles/components.css b/src/styles/components.css index b173571b..bf4549ca 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -106,7 +106,7 @@ font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; - color: var(--ctp-lavender); + color: var(--accent); margin-bottom: var(--space-2); } @@ -148,7 +148,8 @@ } .hero-play-btn:hover { - background: var(--ctp-lavender); + background: var(--accent); + filter: brightness(1.1); transform: scale(1.02); box-shadow: var(--shadow-glow); } @@ -630,13 +631,74 @@ padding: var(--space-4) 0 var(--space-6); } -.album-detail-cover { +.album-detail-cover-btn { + padding: 0; + border-radius: var(--radius-lg); + flex-shrink: 0; + cursor: zoom-in; + position: relative; + overflow: hidden; + display: block; width: clamp(120px, 15vw, 200px); height: clamp(120px, 15vw, 200px); +} + +.album-detail-cover-btn:hover .album-detail-cover { + transform: scale(1.04); +} + +.album-detail-cover { + width: 100%; + height: 100%; border-radius: var(--radius-lg); object-fit: cover; box-shadow: var(--shadow-lg); - flex-shrink: 0; + display: block; + transition: transform 0.2s ease; +} + +/* ─── Cover Lightbox ─── */ +.cover-lightbox-overlay { + position: fixed; + inset: 0; + z-index: 9000; + background: rgba(0, 0, 0, 0.88); + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 8vh; + cursor: zoom-out; + backdrop-filter: blur(6px); +} + +.cover-lightbox-img { + max-width: 90vw; + max-height: 90vh; + object-fit: contain; + border-radius: var(--radius-lg); + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.8); + cursor: default; +} + +.cover-lightbox-close { + position: fixed; + top: 24px; + right: 24px; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.12); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background var(--transition-fast); + z-index: 1; +} + +.cover-lightbox-close:hover { + background: rgba(255, 255, 255, 0.24); } .album-cover-placeholder { @@ -1179,6 +1241,21 @@ border: 1px solid var(--border-subtle); } +.artist-detail-avatar-btn { + display: block; + width: 100%; + height: 100%; + padding: 0; + border: none; + background: none; + cursor: zoom-in; + transition: transform 0.2s ease, filter 0.2s ease; +} +.artist-detail-avatar-btn:hover { + transform: scale(1.04); + filter: brightness(1.08); +} + .artist-detail-meta { flex: 1; min-width: 0; @@ -1422,7 +1499,7 @@ justify-content: space-between; gap: var(--space-4); width: 100%; - padding: var(--space-4) var(--space-5); + padding: 10px var(--space-4); text-align: left; font-size: 14px; font-weight: 500; @@ -1455,7 +1532,7 @@ } .help-answer { - padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px); + padding: 10px var(--space-4) var(--space-4) calc(var(--space-4) + 3px); font-size: 13px; color: var(--text-secondary); line-height: 1.65; @@ -1730,11 +1807,11 @@ } @keyframes ken-burns { - 0% { transform: scale(1.1) translate(-8%, -8%); } - 25% { transform: scale(1.15) translate(8%, -8%); } - 50% { transform: scale(1.1) translate(8%, 8%); } - 75% { transform: scale(1.15) translate(-8%, 8%); } - 100% { transform: scale(1.1) translate(-8%, -8%); } + 0% { transform: scale(1.05) translate(-4%, -1%); } + 25% { transform: scale(1.08) translate( 4%, -1.5%); } + 50% { transform: scale(1.06) translate( 3%, 1%); } + 75% { transform: scale(1.09) translate(-3%, 1.5%); } + 100% { transform: scale(1.05) translate(-4%, -1%); } } /* ── Blurred background ── */ @@ -1742,9 +1819,9 @@ position: absolute; inset: -30%; background-size: cover; - background-position: center center; + background-position: center 20%; filter: blur(6px) brightness(0.25) saturate(1.6); - animation: ken-burns 90s ease-in-out infinite; + animation: ken-burns 120s linear infinite; z-index: 0; will-change: transform; pointer-events: none; @@ -1802,7 +1879,7 @@ font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--ctp-lavender); + color: var(--accent); margin: 0; opacity: 0.9; } @@ -1971,7 +2048,7 @@ } .fs-btn.active { - color: var(--ctp-lavender); + color: var(--accent); } .fs-btn-sm { @@ -1982,15 +2059,16 @@ .fs-btn-play { width: 54px; height: 54px; - background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); + background: var(--accent); color: var(--ctp-crust); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); } .fs-btn-play:hover { - background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); + background: var(--accent); color: var(--ctp-crust); transform: scale(1.07); + filter: brightness(1.12); box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6); } @@ -3011,7 +3089,7 @@ width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-input); - border: 1px solid var(--border); + border: 1px solid var(--border-dropdown, var(--border)); border-radius: var(--radius-sm); color: var(--text-primary); font-size: 0.875rem; @@ -3043,9 +3121,9 @@ } .custom-select-dropdown { background: var(--bg-card); - border: 1px solid var(--border-subtle); + border: 1px solid var(--border-dropdown, var(--border)); border-radius: var(--radius-md); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + box-shadow: 0 8px 32px var(--shadow-dropdown, rgba(0, 0, 0, 0.4)); overflow-y: auto; overscroll-behavior: contain; } diff --git a/src/styles/layout.css b/src/styles/layout.css index d107b19c..86094c78 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -62,7 +62,7 @@ font-size: 20px; font-weight: 700; letter-spacing: -0.02em; - background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue)); + background: linear-gradient(135deg, var(--accent), var(--ctp-blue)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -439,7 +439,7 @@ .player-btn-primary { width: 46px; height: 46px; - background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-lavender)); + background: var(--accent); color: var(--ctp-crust); border-radius: 50%; box-shadow: 0 4px 18px rgba(0, 0, 0, 0.4); @@ -447,10 +447,11 @@ } .player-btn-primary:hover { - background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); + background: var(--accent); color: var(--ctp-crust); transform: scale(1.06); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4), var(--shadow-glow); + filter: brightness(1.12); } /* Waveform seekbar section */ @@ -581,6 +582,121 @@ flex-shrink: 0; } +.queue-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); + flex-shrink: 0; +} +.queue-action-btn:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--text-primary); +} +.queue-action-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.queue-toolbar { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 var(--space-3); + height: 44px; + flex-shrink: 0; + border-bottom: 1px solid var(--border-subtle); + background: var(--bg-app); +} + +.crossfade-popover { + position: absolute; + top: calc(100% + 10px); + right: 0; + width: 170px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px 12px; + box-shadow: 0 4px 16px rgba(0,0,0,0.3); + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; +} + +.crossfade-popover-label { + display: flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--accent); + letter-spacing: 0.03em; +} + +.crossfade-popover-value { + margin-left: auto; + font-size: 12px; + font-weight: 700; + font-family: monospace; + color: var(--text-primary); +} + +.crossfade-popover-slider { + width: 100%; + accent-color: var(--accent); + cursor: pointer; +} + +.crossfade-popover-range { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-muted); +} + +.queue-toolbar-sep { + width: 1px; + height: 18px; + background: var(--border-subtle); + margin: 0 2px; + flex-shrink: 0; +} + +.queue-round-btn { + display: flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 50%; + color: var(--text-primary); + background: var(--bg-hover); + border: none; + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); + flex-shrink: 0; +} +.queue-round-btn:hover:not(:disabled) { + background: var(--border); + color: var(--text-primary); +} +.queue-round-btn:disabled { + opacity: 0.35; + cursor: default; +} +.queue-round-btn.active { + color: var(--ctp-base); + background: var(--accent); +} + .queue-current-track { padding: var(--space-3) var(--space-4); display: flex; @@ -602,6 +718,7 @@ align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.2); + position: relative; } .queue-current-cover img { @@ -635,15 +752,21 @@ } .queue-current-tech { - font-size: 10px; + position: absolute; + bottom: 0; + left: 0; + right: 0; + font-size: 9px; font-family: monospace; letter-spacing: 0.05em; - background: var(--bg-surface); - color: var(--text-muted); - padding: 2px 6px; - border-radius: 4px; - border: 1px solid var(--border-subtle); - display: inline-block; + background: rgba(0, 0, 0, 0.62); + backdrop-filter: blur(4px); + color: rgba(255, 255, 255, 0.85); + padding: 3px 6px; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .queue-divider { diff --git a/src/styles/theme.css b/src/styles/theme.css index 993b5d31..33549db9 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -253,9 +253,11 @@ --accent-glow: rgba(136, 192, 208, 0.3); --text-primary: #eceff4; --text-secondary:#e5e9f0; - --text-muted: #8894a4; - --border: #434c5e; - --border-subtle: #3b4252; + --text-muted: #8894a4; + --border: #434c5e; + --border-subtle: #3b4252; + --border-dropdown: #576070; + --shadow-dropdown: rgba(0, 0, 0, 0.55); --positive: #a3be8c; --warning: #ebcb8b; --danger: #bf616a; @@ -361,9 +363,11 @@ --accent-glow: rgba(136, 192, 208, 0.35); --text-primary: #eceff4; --text-secondary:#e5e9f0; - --text-muted: #7898b4; - --border: #2d3f52; - --border-subtle: #253545; + --text-muted: #7898b4; + --border: #2d3f52; + --border-subtle: #253545; + --border-dropdown: #3d546e; + --shadow-dropdown: rgba(0, 0, 0, 0.6); --positive: #a3be8c; --warning: #ebcb8b; --danger: #bf616a; @@ -414,67 +418,789 @@ --accent-glow: rgba(180, 142, 173, 0.3); --text-primary: #eceff4; --text-secondary:#e5e9f0; - --text-muted: #8894a4; - --border: #434c5e; - --border-subtle: #3b4252; + --text-muted: #8894a4; + --border: #434c5e; + --border-subtle: #3b4252; + --border-dropdown: #576070; + --shadow-dropdown: rgba(0, 0, 0, 0.55); --positive: #a3be8c; --warning: #ebcb8b; --danger: #bf616a; } +/* ─── Poison ─── */ +[data-theme='poison'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%231bd655%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* ── Palette — charcoal grays + phosphor green ── */ + --ctp-crust: #0e0e0e; + --ctp-mantle: #161616; + --ctp-base: #1f1f1f; + --ctp-surface0: #282828; + --ctp-surface1: #323232; + --ctp-surface2: #3e3e3e; + --ctp-overlay0: #525252; + --ctp-overlay1: #686868; + --ctp-overlay2: #848484; + --ctp-text: #d0d0cc; + --ctp-subtext1: #a8a8a4; + --ctp-subtext0: #787874; + /* Phosphor green accent — the Winamp LED display color */ + --ctp-mauve: #1bd655; + --ctp-lavender: #28e860; + --ctp-green: #1bd655; + --ctp-teal: #1ab8a8; + --ctp-sky: #58a8e0; + --ctp-blue: #4888d0; + --ctp-sapphire: #3870b8; + --ctp-pink: #c86888; + --ctp-flamingo: #b85858; + --ctp-rosewater: #c88080; + --ctp-yellow: #c8a830; + --ctp-peach: #c07838; + --ctp-maroon: #a03030; + --ctp-red: #c03030; + + /* ── Semantic tokens ── */ + --bg-app: #1f1f1f; + --bg-sidebar: #161616; + --bg-card: #282828; + --bg-hover: #323232; + --bg-player: #0e0e0e; + --bg-glass: rgba(22, 22, 22, 0.90); + --accent: #1bd655; + --accent-dim: rgba(27, 214, 85, 0.12); + --accent-glow: rgba(27, 214, 85, 0.35); + --text-primary: #d0d0cc; + --text-secondary:#a8a8a4; + --text-muted: #686866; + --border: #3a3a38; + --border-subtle: #282826; + --positive: #1bd655; + --warning: #c8a830; + --danger: #c03030; +} + +/* ── Winamp: LED display glow + tactile buttons ── */ +[data-theme='poison'] .player-track-name { + text-shadow: 0 0 8px rgba(27, 214, 85, 0.7), 0 0 2px rgba(27, 214, 85, 0.4); + color: #1bd655; +} + +[data-theme='poison'] .player-track-artist { + color: #1bd655; + opacity: 0.7; +} + +[data-theme='poison'] .btn { + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +[data-theme='poison'] .player-btn, +[data-theme='poison'] .player-btn-primary { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.12), inset 0 -1px 0 rgba(0, 0, 0, 0.3); +} + +/* ─── Classic Winamp — Winamp 2.x Base Skin ─── */ +[data-theme='classic-winamp'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8c8d0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* ── Palette ── */ + --ctp-crust: #000000; + --ctp-mantle: #1e1e28; + --ctp-base: #2b2b3a; + --ctp-surface0: #353545; + --ctp-surface1: #404055; + --ctp-surface2: #4e4e65; + --ctp-overlay0: #5a5a75; + --ctp-overlay1: #6e6e88; + --ctp-overlay2: #8a8a96; + --ctp-text: #c8c8d0; + --ctp-subtext1: #a0a0ac; + --ctp-subtext0: #8a8a96; + /* Winamp orange accent + iconic green glow */ + --ctp-mauve: #d4cc46; + --ctp-lavender: #e0d860; + --ctp-green: #00ff00; + --ctp-teal: #00cc88; + --ctp-sky: #5ab8e0; + --ctp-blue: #4898d0; + --ctp-sapphire: #3878b8; + --ctp-pink: #c06888; + --ctp-flamingo: #b85858; + --ctp-rosewater: #c88080; + --ctp-yellow: #e8c050; + --ctp-peach: #d08840; + --ctp-maroon: #a03030; + --ctp-red: #c03030; + + /* ── Semantic tokens ── */ + --bg-app: #2b2b3a; + --bg-sidebar: #1e1e28; + --bg-card: #353545; + --bg-hover: #404055; + --bg-player: #000000; + --bg-glass: rgba(43, 43, 58, 0.90); + --accent: #d4cc46; + --accent-dim: rgba(212, 204, 70, 0.15); + --accent-glow: rgba(212, 204, 70, 0.35); + --volume-accent: #de9b35; + --text-primary: #c8c8d0; + --text-secondary:#a0a0ac; + --text-muted: #6e6e88; + --border: #5a5a75; + --border-subtle: #353545; + --positive: #00cc66; + --warning: #d4cc46; + --danger: #c03030; +} + +/* ── Classic Winamp: LCD display + bevel buttons ── */ +[data-theme='classic-winamp'] .player-track-name { + color: #00ff00; + text-shadow: 0 0 6px rgba(0, 255, 0, 0.65), 0 0 2px rgba(0, 255, 0, 0.4); + font-family: 'Courier New', 'Lucida Console', monospace; + letter-spacing: 0.04em; +} + +[data-theme='classic-winamp'] .player-track-artist { + color: #00cc00; + font-family: 'Courier New', 'Lucida Console', monospace; + opacity: 0.85; +} + +[data-theme='classic-winamp'] .btn { + box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 1px 3px rgba(0,0,0,0.4); +} + +[data-theme='classic-winamp'] .player-btn, +[data-theme='classic-winamp'] .player-btn-primary { + box-shadow: inset 1px 1px 0 rgba(255,255,255,0.18), inset -1px -1px 0 rgba(0,0,0,0.55), 0 2px 5px rgba(0,0,0,0.5); +} + +/* ─── Nucleo — Classic HiFi / Winamp Brass ─── */ +[data-theme='nucleo'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%238c7356%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* ── Palette ── */ + --ctp-crust: #c8a458; + --ctp-mantle: #dfc08f; + --ctp-base: #e9d3ac; + --ctp-surface0: #eedbb8; + --ctp-surface1: #f5e4c3; + --ctp-surface2: #f9ecd4; + --ctp-overlay0: #b09060; + --ctp-overlay1: #987848; + --ctp-overlay2: #805c30; + --ctp-text: #2c1e10; + --ctp-subtext1: #5a3e20; + --ctp-subtext0: #8c7356; + /* Accents — silver metal + muted brass tones */ + --ctp-mauve: #9e9a92; /* silver — primary accent */ + --ctp-lavender: #b8b4ac; /* lighter silver */ + --ctp-pink: #c06880; + --ctp-flamingo: #c06050; + --ctp-rosewater: #d08070; + --ctp-blue: #5878a8; /* muted steel blue */ + --ctp-sapphire: #4868a0; + --ctp-sky: #6898b8; + --ctp-teal: #487878; + --ctp-green: #587838; /* muted olive */ + --ctp-yellow: #a88020; /* dark burnished gold */ + --ctp-peach: #b87040; + --ctp-maroon: #903030; + --ctp-red: #a03828; + + /* ── Semantic tokens ── */ + --bg-app: #f5e4c3; + --bg-sidebar: #e9d3ac; + --bg-card: #eedbb8; + --bg-hover: #e3cc9e; + --bg-player: #dfc08f; + --bg-glass: rgba(245, 228, 195, 0.90); + --accent: #9e9a92; + --accent-dim: rgba(158, 154, 146, 0.15); + --accent-glow: rgba(158, 154, 146, 0.35); + --text-primary: #2c1e10; + --text-secondary:#5a3e20; + --text-muted: #8c7356; + --border: #b89a69; + --border-subtle: #dfc08f; + --positive: #587838; + --warning: #a88020; + --danger: #a03828; +} + +/* ── Nucleo: skeuomorphic detail layer ── */ +[data-theme='nucleo'] .sidebar { + background-image: repeating-linear-gradient( + 90deg, + rgba(255,255,255,0.06) 0px, + rgba(255,255,255,0.06) 1px, + transparent 1px, + transparent 5px + ); +} + +[data-theme='nucleo'] .player-bar { + background-image: repeating-linear-gradient( + 90deg, + rgba(255,255,255,0.05) 0px, + rgba(255,255,255,0.05) 1px, + transparent 1px, + transparent 5px + ); +} + +[data-theme='nucleo'] .player-track-name { + text-shadow: 0 0 10px rgba(255, 244, 219, 0.75), 0 0 2px rgba(255, 244, 219, 0.4); +} + +[data-theme='nucleo'] .btn { + box-shadow: 0 2px 4px rgba(44, 30, 16, 0.20), inset 0 1px 0 rgba(255, 255, 255, 0.35); +} + +[data-theme='nucleo'] .btn-surface { + background: #c8a860; + color: #2c1e10; +} +[data-theme='nucleo'] .btn-surface:hover { background: #b89448; } + +[data-theme='nucleo'] .btn-ghost:hover { + background: #c8a860; + color: #2c1e10; +} + +[data-theme='nucleo'] .player-btn, +[data-theme='nucleo'] .player-btn-primary { + box-shadow: 0 2px 5px rgba(44, 30, 16, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.4); +} + /* ─── Psychowave — Synthwave / Retrowave ─── */ [data-theme='psychowave'] { color-scheme: dark; - --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c9a8ff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23b09ee0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); /* ── Palette ── */ - --ctp-crust: #120720; - --ctp-mantle: #160a28; - --ctp-base: #1c0b35; - --ctp-surface0: #2a1050; - --ctp-surface1: #381860; - --ctp-surface2: #4a2878; - --ctp-overlay0: #5e3d90; - --ctp-overlay1: #7b50b8; - --ctp-overlay2: #9868d8; - /* Soft lavender text */ - --ctp-text: #f0e6ff; - --ctp-subtext1: #c9a8ff; - --ctp-subtext0: #a882e0; - /* Neon accents */ - --ctp-mauve: #ff2d78; /* hot pink — primary accent & button gradient start */ - --ctp-lavender: #bf5fff; /* electric purple — button gradient end */ - --ctp-pink: #ff2d78; - --ctp-flamingo: #ff6b9d; - --ctp-rosewater: #ff8fc0; - --ctp-blue: #00d4ff; /* electric cyan */ - --ctp-sapphire: #007aff; - --ctp-sky: #00bfff; - --ctp-teal: #00e5cc; - --ctp-green: #39ff14; /* neon green */ - --ctp-yellow: #ffd700; /* neon gold */ - --ctp-peach: #ff8c42; - --ctp-maroon: #ff4d6d; - --ctp-red: #ff2d55; + --ctp-crust: #0d0c1a; + --ctp-mantle: #110f22; + --ctp-base: #161428; + --ctp-surface0: #1f1c38; + --ctp-surface1: #2a2750; + --ctp-surface2: #383468; + --ctp-overlay0: #4a4580; + --ctp-overlay1: #6460a8; + --ctp-overlay2: #8078c8; + /* Text */ + --ctp-text: #e6e0f6; + --ctp-subtext1: #b09ee0; + --ctp-subtext0: #8878c0; + /* Accents — muted synthwave palette */ + --ctp-mauve: #a06ae0; /* soft violet — primary accent */ + --ctp-lavender: #7878d8; /* muted indigo — gradient end */ + --ctp-pink: #c060a8; /* dusty rose */ + --ctp-flamingo: #c07090; + --ctp-rosewater: #c89090; + --ctp-blue: #58a8e8; /* steel blue */ + --ctp-sapphire: #4880c8; + --ctp-sky: #60c0d8; + --ctp-teal: #40a8a8; + --ctp-green: #58c878; /* soft mint green */ + --ctp-yellow: #c8a848; /* muted gold */ + --ctp-peach: #c87848; + --ctp-maroon: #c05868; + --ctp-red: #b04060; /* ── Semantic tokens ── */ - --bg-app: #1c0b35; - --bg-sidebar: #160a28; - --bg-card: #2a1050; - --bg-hover: #381860; - --bg-player: #120720; - --bg-glass: rgba(28, 11, 53, 0.82); - --accent: #ff2d78; - --accent-dim: rgba(255, 45, 120, 0.15); - --accent-glow: rgba(255, 45, 120, 0.45); - --text-primary: #f0e6ff; - --text-secondary:#c9a8ff; - --text-muted: #9b72d0; - --border: #4a2878; - --border-subtle: #2a1050; - --positive: #39ff14; - --warning: #ffd700; - --danger: #ff2d55; + --bg-app: #161428; + --bg-sidebar: #110f22; + --bg-card: #1f1c38; + --bg-hover: #2a2750; + --bg-player: #0d0c1a; + --bg-glass: rgba(22, 20, 40, 0.85); + --accent: #a06ae0; + --accent-dim: rgba(160, 106, 224, 0.12); + --accent-glow: rgba(160, 106, 224, 0.30); + --text-primary: #e6e0f6; + --text-secondary:#b09ee0; + --text-muted: #706898; + --border: #383468; + --border-subtle: #1f1c38; + --positive: #58c878; + --warning: #c8a848; + --danger: #b04060; +} + +/* ─── Gruvbox Dark Hard ─── */ +[data-theme='gruvbox-dark-hard'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #1d2021; + --ctp-mantle: #1d2021; + --ctp-base: #282828; + --ctp-surface0: #3c3836; + --ctp-surface1: #504945; + --ctp-surface2: #665c54; + --ctp-overlay0: #7c6f64; + --ctp-overlay1: #928374; + --ctp-overlay2: #a89984; + --ctp-text: #ebdbb2; + --ctp-subtext1: #d5c4a1; + --ctp-subtext0: #bdae93; + --ctp-mauve: #d3869b; + --ctp-lavender: #d3869b; + --ctp-pink: #d3869b; + --ctp-flamingo: #fb4934; + --ctp-rosewater: #fb4934; + --ctp-blue: #83a598; + --ctp-sapphire: #83a598; + --ctp-sky: #8ec07c; + --ctp-teal: #8ec07c; + --ctp-green: #b8bb26; + --ctp-yellow: #fabd2f; + --ctp-peach: #fe8019; + --ctp-maroon: #fb4934; + --ctp-red: #cc241d; + + --bg-app: #1d2021; + --bg-sidebar: #141617; + --bg-card: #3c3836; + --bg-hover: #504945; + --bg-player: #1d2021; + --bg-glass: rgba(29, 32, 33, 0.82); + --accent: #fabd2f; + --accent-dim: rgba(250, 189, 47, 0.15); + --accent-glow: rgba(250, 189, 47, 0.3); + --text-primary: #ebdbb2; + --text-secondary:#d5c4a1; + --text-muted: #928374; + --border: #504945; + --border-subtle: #3c3836; + --border-dropdown: #665c54; + --shadow-dropdown: rgba(0, 0, 0, 0.6); + --positive: #b8bb26; + --warning: #fabd2f; + --danger: #fb4934; +} + +/* ─── Gruvbox Dark Medium ─── */ +[data-theme='gruvbox-dark-medium'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #1d2021; + --ctp-mantle: #1d2021; + --ctp-base: #282828; + --ctp-surface0: #3c3836; + --ctp-surface1: #504945; + --ctp-surface2: #665c54; + --ctp-overlay0: #7c6f64; + --ctp-overlay1: #928374; + --ctp-overlay2: #a89984; + --ctp-text: #ebdbb2; + --ctp-subtext1: #d5c4a1; + --ctp-subtext0: #bdae93; + --ctp-mauve: #d3869b; + --ctp-lavender: #d3869b; + --ctp-pink: #d3869b; + --ctp-flamingo: #fb4934; + --ctp-rosewater: #fb4934; + --ctp-blue: #83a598; + --ctp-sapphire: #83a598; + --ctp-sky: #8ec07c; + --ctp-teal: #8ec07c; + --ctp-green: #b8bb26; + --ctp-yellow: #fabd2f; + --ctp-peach: #fe8019; + --ctp-maroon: #fb4934; + --ctp-red: #cc241d; + + --bg-app: #282828; + --bg-sidebar: #1d2021; + --bg-card: #3c3836; + --bg-hover: #504945; + --bg-player: #1d2021; + --bg-glass: rgba(40, 40, 40, 0.82); + --accent: #fabd2f; + --accent-dim: rgba(250, 189, 47, 0.15); + --accent-glow: rgba(250, 189, 47, 0.3); + --text-primary: #ebdbb2; + --text-secondary:#d5c4a1; + --text-muted: #928374; + --border: #504945; + --border-subtle: #3c3836; + --border-dropdown: #665c54; + --shadow-dropdown: rgba(0, 0, 0, 0.55); + --positive: #b8bb26; + --warning: #fabd2f; + --danger: #fb4934; +} + +/* ─── Gruvbox Dark Soft ─── */ +[data-theme='gruvbox-dark-soft'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ebdbb2%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #1d2021; + --ctp-mantle: #282828; + --ctp-base: #32302f; + --ctp-surface0: #3c3836; + --ctp-surface1: #504945; + --ctp-surface2: #665c54; + --ctp-overlay0: #7c6f64; + --ctp-overlay1: #928374; + --ctp-overlay2: #a89984; + --ctp-text: #ebdbb2; + --ctp-subtext1: #d5c4a1; + --ctp-subtext0: #bdae93; + --ctp-mauve: #d3869b; + --ctp-lavender: #d3869b; + --ctp-pink: #d3869b; + --ctp-flamingo: #fb4934; + --ctp-rosewater: #fb4934; + --ctp-blue: #83a598; + --ctp-sapphire: #83a598; + --ctp-sky: #8ec07c; + --ctp-teal: #8ec07c; + --ctp-green: #b8bb26; + --ctp-yellow: #fabd2f; + --ctp-peach: #fe8019; + --ctp-maroon: #fb4934; + --ctp-red: #cc241d; + + --bg-app: #32302f; + --bg-sidebar: #282828; + --bg-card: #45403d; + --bg-hover: #504945; + --bg-player: #282828; + --bg-glass: rgba(50, 48, 47, 0.82); + --accent: #fabd2f; + --accent-dim: rgba(250, 189, 47, 0.15); + --accent-glow: rgba(250, 189, 47, 0.3); + --text-primary: #ebdbb2; + --text-secondary:#d5c4a1; + --text-muted: #928374; + --border: #504945; + --border-subtle: #3c3836; + --border-dropdown: #665c54; + --shadow-dropdown: rgba(0, 0, 0, 0.5); + --positive: #b8bb26; + --warning: #fabd2f; + --danger: #fb4934; +} + +/* ─── Gruvbox Light Hard ─── */ +[data-theme='gruvbox-light-hard'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #ebdbb2; + --ctp-mantle: #f2e5bc; + --ctp-base: #f9f5d7; + --ctp-surface0: #f2e5bc; + --ctp-surface1: #ebdbb2; + --ctp-surface2: #d5c4a1; + --ctp-overlay0: #bdae93; + --ctp-overlay1: #a89984; + --ctp-overlay2: #928374; + --ctp-text: #3c3836; + --ctp-subtext1: #504945; + --ctp-subtext0: #665c54; + --ctp-mauve: #8f3f71; + --ctp-lavender: #8f3f71; + --ctp-pink: #8f3f71; + --ctp-flamingo: #9d0006; + --ctp-rosewater: #cc241d; + --ctp-blue: #076678; + --ctp-sapphire: #076678; + --ctp-sky: #427b58; + --ctp-teal: #427b58; + --ctp-green: #79740e; + --ctp-yellow: #b57614; + --ctp-peach: #af3a03; + --ctp-maroon: #9d0006; + --ctp-red: #cc241d; + + --bg-app: #f9f5d7; + --bg-sidebar: #f2e5bc; + --bg-card: #f2e5bc; + --bg-hover: #ebdbb2; + --bg-player: #f2e5bc; + --bg-glass: rgba(249, 245, 215, 0.9); + --accent: #b57614; + --accent-dim: rgba(181, 118, 20, 0.15); + --accent-glow: rgba(181, 118, 20, 0.25); + --text-primary: #3c3836; + --text-secondary:#504945; + --text-muted: #7c6f64; + --border: #d5c4a1; + --border-subtle: #ebdbb2; + --positive: #79740e; + --warning: #b57614; + --danger: #9d0006; +} + +/* ─── Gruvbox Light Medium ─── */ +[data-theme='gruvbox-light-medium'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #ebdbb2; + --ctp-mantle: #f2e5bc; + --ctp-base: #fbf1c7; + --ctp-surface0: #f2e5bc; + --ctp-surface1: #ebdbb2; + --ctp-surface2: #d5c4a1; + --ctp-overlay0: #bdae93; + --ctp-overlay1: #a89984; + --ctp-overlay2: #928374; + --ctp-text: #3c3836; + --ctp-subtext1: #504945; + --ctp-subtext0: #665c54; + --ctp-mauve: #8f3f71; + --ctp-lavender: #8f3f71; + --ctp-pink: #8f3f71; + --ctp-flamingo: #9d0006; + --ctp-rosewater: #cc241d; + --ctp-blue: #076678; + --ctp-sapphire: #076678; + --ctp-sky: #427b58; + --ctp-teal: #427b58; + --ctp-green: #79740e; + --ctp-yellow: #b57614; + --ctp-peach: #af3a03; + --ctp-maroon: #9d0006; + --ctp-red: #cc241d; + + --bg-app: #fbf1c7; + --bg-sidebar: #f2e5bc; + --bg-card: #f2e5bc; + --bg-hover: #ebdbb2; + --bg-player: #f2e5bc; + --bg-glass: rgba(251, 241, 199, 0.9); + --accent: #b57614; + --accent-dim: rgba(181, 118, 20, 0.15); + --accent-glow: rgba(181, 118, 20, 0.25); + --text-primary: #3c3836; + --text-secondary:#504945; + --text-muted: #7c6f64; + --border: #d5c4a1; + --border-subtle: #ebdbb2; + --positive: #79740e; + --warning: #b57614; + --danger: #9d0006; +} + +/* ─── Gruvbox Light Soft ─── */ +[data-theme='gruvbox-light-soft'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23504945%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #ebdbb2; + --ctp-mantle: #f2e5bc; + --ctp-base: #f2e5bc; + --ctp-surface0: #ebdbb2; + --ctp-surface1: #d5c4a1; + --ctp-surface2: #bdae93; + --ctp-overlay0: #a89984; + --ctp-overlay1: #928374; + --ctp-overlay2: #7c6f64; + --ctp-text: #3c3836; + --ctp-subtext1: #504945; + --ctp-subtext0: #665c54; + --ctp-mauve: #8f3f71; + --ctp-lavender: #8f3f71; + --ctp-pink: #8f3f71; + --ctp-flamingo: #9d0006; + --ctp-rosewater: #cc241d; + --ctp-blue: #076678; + --ctp-sapphire: #076678; + --ctp-sky: #427b58; + --ctp-teal: #427b58; + --ctp-green: #79740e; + --ctp-yellow: #b57614; + --ctp-peach: #af3a03; + --ctp-maroon: #9d0006; + --ctp-red: #cc241d; + + --bg-app: #f2e5bc; + --bg-sidebar: #ebdbb2; + --bg-card: #ebdbb2; + --bg-hover: #d5c4a1; + --bg-player: #ebdbb2; + --bg-glass: rgba(242, 229, 188, 0.9); + --accent: #b57614; + --accent-dim: rgba(181, 118, 20, 0.15); + --accent-glow: rgba(181, 118, 20, 0.25); + --text-primary: #3c3836; + --text-secondary:#504945; + --text-muted: #7c6f64; + --border: #d5c4a1; + --border-subtle: #ebdbb2; + --positive: #79740e; + --warning: #b57614; + --danger: #9d0006; +} + +/* ─── Tokyo Night ─── */ +[data-theme='tokyo-night'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #16161e; + --ctp-mantle: #1a1b26; + --ctp-base: #1a1b26; + --ctp-surface0: #24283b; + --ctp-surface1: #2f334d; + --ctp-surface2: #3b3f5c; + --ctp-overlay0: #414868; + --ctp-overlay1: #565f89; + --ctp-overlay2: #6272a4; + --ctp-text: #c0caf5; + --ctp-subtext1: #a9b1d6; + --ctp-subtext0: #9aa5ce; + --ctp-mauve: #bb9af7; + --ctp-lavender: #c0caf5; + --ctp-pink: #f7768e; + --ctp-flamingo: #ff9e64; + --ctp-rosewater: #f7768e; + --ctp-blue: #7aa2f7; + --ctp-sapphire: #7dcfff; + --ctp-sky: #7dcfff; + --ctp-teal: #2ac3de; + --ctp-green: #9ece6a; + --ctp-yellow: #e0af68; + --ctp-peach: #ff9e64; + --ctp-maroon: #f7768e; + --ctp-red: #f7768e; + + --bg-app: #1a1b26; + --bg-sidebar: #16161e; + --bg-card: #24283b; + --bg-hover: #2f334d; + --bg-player: #16161e; + --bg-glass: rgba(26, 27, 38, 0.82); + --accent: #7aa2f7; + --accent-dim: rgba(122, 162, 247, 0.15); + --accent-glow: rgba(122, 162, 247, 0.3); + --text-primary: #c0caf5; + --text-secondary:#a9b1d6; + --text-muted: #565f89; + --border: #2f334d; + --border-subtle: #24283b; + --border-dropdown: #3d425e; + --shadow-dropdown: rgba(0, 0, 0, 0.55); + --positive: #9ece6a; + --warning: #e0af68; + --danger: #f7768e; +} + +/* ─── Tokyo Night Storm ─── */ +[data-theme='tokyo-night-storm'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23a9b1d6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #1f2335; + --ctp-mantle: #1f2335; + --ctp-base: #24283b; + --ctp-surface0: #2f334d; + --ctp-surface1: #3b3f5c; + --ctp-surface2: #414868; + --ctp-overlay0: #565f89; + --ctp-overlay1: #6272a4; + --ctp-overlay2: #737aa2; + --ctp-text: #c0caf5; + --ctp-subtext1: #a9b1d6; + --ctp-subtext0: #9aa5ce; + --ctp-mauve: #bb9af7; + --ctp-lavender: #c0caf5; + --ctp-pink: #f7768e; + --ctp-flamingo: #ff9e64; + --ctp-rosewater: #f7768e; + --ctp-blue: #7aa2f7; + --ctp-sapphire: #7dcfff; + --ctp-sky: #7dcfff; + --ctp-teal: #2ac3de; + --ctp-green: #9ece6a; + --ctp-yellow: #e0af68; + --ctp-peach: #ff9e64; + --ctp-maroon: #f7768e; + --ctp-red: #f7768e; + + --bg-app: #24283b; + --bg-sidebar: #1f2335; + --bg-card: #2f334d; + --bg-hover: #3b3f5c; + --bg-player: #1f2335; + --bg-glass: rgba(36, 40, 59, 0.82); + --accent: #7aa2f7; + --accent-dim: rgba(122, 162, 247, 0.15); + --accent-glow: rgba(122, 162, 247, 0.3); + --text-primary: #c0caf5; + --text-secondary:#a9b1d6; + --text-muted: #565f89; + --border: #3b3f5c; + --border-subtle: #2f334d; + --border-dropdown: #414868; + --shadow-dropdown: rgba(0, 0, 0, 0.55); + --positive: #9ece6a; + --warning: #e0af68; + --danger: #f7768e; +} + +/* ─── Tokyo Night Light ─── */ +[data-theme='tokyo-night-light'] { + color-scheme: light; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23343b58%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + --ctp-crust: #cbccd1; + --ctp-mantle: #d5d6db; + --ctp-base: #d5d6db; + --ctp-surface0: #e9e9ec; + --ctp-surface1: #c4c5cc; + --ctp-surface2: #b4b5be; + --ctp-overlay0: #a0a1ab; + --ctp-overlay1: #9699a3; + --ctp-overlay2: #8488a0; + --ctp-text: #343b58; + --ctp-subtext1: #565a6e; + --ctp-subtext0: #6b6f85; + --ctp-mauve: #5a4a78; + --ctp-lavender: #34548a; + --ctp-pink: #8f2f5c; + --ctp-flamingo: #965027; + --ctp-rosewater: #8c4351; + --ctp-blue: #34548a; + --ctp-sapphire: #0f4b6e; + --ctp-sky: #0f4b6e; + --ctp-teal: #0f4b6e; + --ctp-green: #485e30; + --ctp-yellow: #8f5e15; + --ctp-peach: #965027; + --ctp-maroon: #8c4351; + --ctp-red: #8c4351; + + --bg-app: #d5d6db; + --bg-sidebar: #cbccd1; + --bg-card: #e9e9ec; + --bg-hover: #c4c5cc; + --bg-player: #cbccd1; + --bg-glass: rgba(213, 214, 219, 0.9); + --accent: #34548a; + --accent-dim: rgba(52, 84, 138, 0.15); + --accent-glow: rgba(52, 84, 138, 0.25); + --text-primary: #343b58; + --text-secondary:#565a6e; + --text-muted: #9699a3; + --border: #c4c5cc; + --border-subtle: #d5d6db; + --positive: #485e30; + --warning: #8f5e15; + --danger: #8c4351; } /* ─── Global Base Settings ─── */ @@ -525,6 +1251,8 @@ html, body, #root { height: 100%; + min-width: 1280px; + min-height: 720px; overflow: hidden; } @@ -616,7 +1344,7 @@ body.is-dragging * { color: var(--ctp-crust); font-weight: 600; } -.btn-primary:hover { background: var(--ctp-lavender); transform: translateY(-1px); box-shadow: var(--shadow-glow); } +.btn-primary:hover { background: var(--accent); filter: brightness(1.1); transform: translateY(-1px); box-shadow: var(--shadow-glow); } .btn-primary:active { transform: translateY(0); } .btn-ghost {