From d927ef2082e1151637386c644c8349156dee1231 Mon Sep 17 00:00:00 2001 From: Psychotoxical Date: Sun, 22 Mar 2026 13:10:06 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20v1.12.0=20=E2=80=94=20Lyrics,=2015=20ne?= =?UTF-8?q?w=20themes,=20Last.fm=20stable,=20Crossfade=20stable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 44 + CLAUDE.md | 71 +- README.md | 14 +- package.json | 2 +- packages/aur/PKGBUILD | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio.rs | 400 +++++++-- src-tauri/tauri.conf.json | 5 +- src/api/lrclib.ts | 47 ++ src/components/AlbumHeader.tsx | 2 +- src/components/AlbumTrackList.tsx | 2 +- src/components/FullscreenPlayer.tsx | 16 +- src/components/LyricsPane.tsx | 109 +++ src/components/PlayerBar.tsx | 18 +- src/components/QueuePanel.tsx | 32 +- src/components/ThemePicker.tsx | 324 ++++---- src/i18n.ts | 20 +- src/pages/NowPlaying.tsx | 25 +- src/store/lyricsStore.ts | 17 + src/store/playerStore.ts | 72 ++ src/store/themeStore.ts | 2 +- src/styles/components.css | 170 ++++ src/styles/theme.css | 1181 +++++++++++++++++++++++---- 24 files changed, 2168 insertions(+), 411 deletions(-) create mode 100644 src/api/lrclib.ts create mode 100644 src/components/LyricsPane.tsx create mode 100644 src/store/lyricsStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d7acda0b..ed48ded5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,50 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.12.0] - 2026-03-22 + +### Added + +- **Synchronized Lyrics**: Lyrics pane integrated into the Queue sidebar, powered by [LRCLIB](https://lrclib.net) — no API key required. Shows time-synced lyrics with auto-scroll and active-line highlighting; falls back to plain text when synced lyrics are unavailable. Access via the microphone icon in the player bar, fullscreen player, or Now Playing page. + +#### 15 New Themes + +**Games** (new group — 6 themes): +- **Ascalon**: Dark stone fantasy inspired by Guild Wars 1. Near-black base, gold accent (`#d4af37`). +- **Azerothian Gold**: World of Warcraft inspired. Charcoal base, warm gold accent (`#c19e67`). +- **Grand Theft Audio**: GTA-inspired night city aesthetic. Pure black base, green accent (`#57b05a`). +- **Lambda 17**: Half-Life inspired. Deep blue-black base, amber accent (`#ff9d00`). +- **NightCity 2077**: Cyberpunk 2077 inspired. Near-total black base, neon yellow accent (`#FCEE0A`). +- **V-Tactical**: Battlefield inspired. Gunmetal base, burnt orange accent (`#ff8a00`). + +**Series** (new group — 3 themes): +- **A Theme of Ice and Fire**: Game of Thrones inspired. Cold dark navy base, ice blue accent (`#70a1ff`). +- **D'oh-matic**: The Simpsons inspired. Cream/yellow light base, blue accent (`#1F75FE`). +- **Heisenberg**: Breaking Bad inspired. Dark desaturated green base, crystal blue accent (`#3fe0ff`). + +**Movies** (2 additions): +- **Imperial Sith**: Star Wars dark side. Pure black base, red accent (`#e60000`). +- **Order of the Phoenix**: Harry Potter inspired. Deep charcoal base, ember-orange accent (`#e63900`). + +**Operating Systems** (1 addition): +- **W98**: Windows 98 teal desktop aesthetic. Classic teal background, silver card, navy accent (`#000080`). + +### Changed + +- **Last.fm integration**: Promoted out of beta — scrobbling, Now Playing, love/unlove, Similar Artists, and top stats are considered stable. +- **Crossfade**: No longer marked experimental. Stable on Windows and Linux; macOS under observation. +- **Gapless playback**: Experimental badge removed — considered stable. +- **Theme picker — groups reorganised**: Catppuccin, Nord, and Retro (Gruvbox) merged into a single **Open Source Classics** group. Streaming themes (Spotless, DZR, Cupertino Beats) moved into **Psysonic Themes — Mediaplayer**. The app now ships **47 themes** across **7 groups**. +- **Tokyo Night themes removed**: `tokyo-night`, `tokyo-night-storm`, and `tokyo-night-light` retired to make room for the new groups. +- **Settings — tab order**: Reordered to Server → Appearance → Playback → Library → Shortcuts → About. +- **Settings — Theme picker**: "Betriebssysteme" group renamed to "Operating Systems". + +### Fixed + +- **Text selection on double-click**: Double-clicking song titles or anywhere in the UI no longer accidentally selects text. `user-select: none` applied globally; re-enabled for bio/description text areas. +- **Middle Earth theme — star buttons**: Active favourite star in the album tracklist and album header was barely visible (gold on parchment, ~1.4:1 contrast). Both active and inactive states now use darker brown tones with proper contrast. +- **Middle Earth theme — play button hover**: Hovering the primary play/pause button no longer makes the icon invisible (gold icon on gold background). + ## [1.11.0] - 2026-03-22 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1bd6c3da..73ac1ade 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,7 +50,10 @@ There are no test scripts. TypeScript compilation (`tsc`) is part of the build. | `src/store/authStore.ts` | Multi-server support via `ServerProfile[]` + `activeServerId`. `getBaseUrl()` / `getActiveServer()` used by subsonic.ts. Also stores Last.fm session key, username, scrobbling toggle. Persisted via **`localStorage`** (synchronous — do not change to async storage). | | `src/store/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 (30 themes), applied as `data-theme` on `` | +| `src/store/themeStore.ts` | Theme selection (47 themes across 7 groups), applied as `data-theme` on `` | +| `src/store/lyricsStore.ts` | Sidebar tab state (`activeTab: 'queue' \| 'lyrics'`). `showLyrics()` / `showQueue()` / `setTab()`. Not persisted. | +| `src/components/LyricsPane.tsx` | Lyrics pane rendered inside QueuePanel when `activeTab === 'lyrics'`. Fetches from LRCLIB, parses LRC, auto-scrolls active line. Only subscribes to `currentTime` when synced lyrics are present. | +| `src/api/lrclib.ts` | Fetches lyrics from `https://lrclib.net/api/get`. Returns `{ syncedLyrics, plainLyrics }`. `parseLrc()` parses LRC timestamps into sorted `LrcLine[]`. | | `src/store/fontStore.ts` | Font selection (10 fonts), applied as `data-font` on ``. Persisted in `psysonic_font`. | | `src/store/keybindingsStore.ts` | Configurable keybindings — maps `KeyAction` to `e.code` strings. Persisted in `psysonic_keybindings`. | | `src/utils/playAlbum.ts` | `playAlbum(albumId)` — fetches album, fades out current track (700 ms), restores volume in store only (no Rust invoke), calls `playTrack`. Used by `AlbumCard` and `Hero` play buttons. | @@ -110,7 +113,7 @@ Use `getActiveServer()` to get the current server, `getBaseUrl()` to get its URL Add a function to `src/api/subsonic.ts` using the `api()` helper. The helper automatically injects auth params and unwraps `subsonic-response`. ### Themes -30 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. +47 themes across 7 groups, selectable in Settings via `ThemePicker`. `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. `--volume-accent` overrides the volume slider colour independently of `--accent` (used by WnAmp for orange volume, yellow accent elsewhere). @@ -125,30 +128,44 @@ Add a function to `src/api/subsonic.ts` using the `api()` helper. The helper | `navy-jukebox` | Psysonic — Mediaplayer | silver/blue light | Blue `#0070a0` | | `cobalt-media` | Psysonic — Mediaplayer | cobalt blue dark | Lime `#45ff00` | | `onyx-cinema` | Psysonic — Mediaplayer | near-black cinematic | Cyan `#00aaff` | -| `cupertino-light` | Betriebssysteme | macOS light, frosted glass | Apple Blue `#0071e3` | -| `cupertino-dark` | Betriebssysteme | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` | -| `aero-glass` | Betriebssysteme | Win7 Aero glass blue | Blue `#1878e8` | -| `luna-teal` | Betriebssysteme | WinXP Luna, green gel buttons | Green `#3c9d29` | -| `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 | Blue `#7aa2f7` | -| `tokyo-night-storm` | Tokyo Night | stormy blue-gray | Blue `#7aa2f7` | -| `tokyo-night-light` | Tokyo Night | light | Blue `#7aa2f7` | -| `spotless` | Streaming Series | flat dark, Spotify-inspired | Green `#1ED760` | -| `dzr0` | Streaming Series | flat light, Deezer-inspired | Purple `#A238FF` | -| `cupertino-beats` | Streaming Series | Apple Music dark, glassmorphism | Red `#fa243c` | +| `spotless` | Psysonic — Mediaplayer | flat dark, Spotify-inspired | Green `#1ED760` | +| `dzr0` | Psysonic — Mediaplayer | flat light, Deezer-inspired | Purple `#A238FF` | +| `cupertino-beats` | Psysonic — Mediaplayer | Apple Music dark, glassmorphism | Red `#fa243c` | +| `cupertino-light` | Operating Systems | macOS light, frosted glass | Apple Blue `#0071e3` | +| `cupertino-dark` | Operating Systems | macOS Space Grey, frosted glass | Vibrant Blue `#007aff` | +| `aero-glass` | Operating Systems | Win7 Aero glass blue | Blue `#1878e8` | +| `w98` | Operating Systems | Windows 98 teal desktop | Navy `#000080` | +| `luna-teal` | Operating Systems | WinXP Luna, green gel buttons | Green `#3c9d29` | +| `ascalon` | Games | Guild Wars 1 dark stone fantasy | Gold `#d4af37` | +| `azerothian-gold` | Games | World of Warcraft | Gold `#c19e67` | +| `grand-theft-audio` | Games | GTA night city | Green `#57b05a` | +| `lambda-17` | Games | Half-Life orange alert | Amber `#ff9d00` | +| `nightcity-2077` | Games | Cyberpunk 2077 | Neon Yellow `#FCEE0A` | +| `v-tactical` | Games | Battlefield | Burnt Orange `#ff8a00` | +| `blade` | Movies | deep black, blood-red | Red `#b30000` | +| `imperial-sith` | Movies | Star Wars dark side | Red `#e60000` | +| `middle-earth` | Movies | warm parchment light (LOTR) | Gold `#d4af37` | +| `morpheus` | Movies | Matrix terminal | Phosphor Green `#00ff41` | +| `order-of-the-phoenix` | Movies | Harry Potter | Ember Orange `#e63900` | +| `pandora` | Movies | Avatar bioluminescent | Cyan `#00f2ff` | +| `stark-hud` | Movies | Iron Man HUD | Cyan `#00f2ff` | +| `ice-and-fire` | Series | Game of Thrones | Ice Blue `#70a1ff` | +| `doh-matic` | Series | The Simpsons | Blue `#1F75FE` | +| `heisenberg` | Series | Breaking Bad | Crystal Blue `#3fe0ff` | +| `mocha` | Open Source Classics | Catppuccin dark | Mauve | +| `macchiato` | Open Source Classics | Catppuccin medium-dark | Mauve | +| `frappe` | Open Source Classics | Catppuccin medium | Mauve | +| `latte` | Open Source Classics | Catppuccin light | Mauve | +| `nord` | Open Source Classics | Polar Night dark | Frost `#88c0d0` | +| `nord-snowstorm` | Open Source Classics | Snow Storm light | Deep-Blue `#5e81ac` | +| `nord-frost` | Open Source Classics | deep ocean blue | Frost `#88c0d0` | +| `nord-aurora` | Open Source Classics | Polar Night + aurora | Purple `#b48ead` | +| `gruvbox-dark-hard` | Open Source Classics | Gruvbox dark hard | Orange `#fe8019` | +| `gruvbox-dark-medium` | Open Source Classics | Gruvbox dark medium | Orange `#fe8019` | +| `gruvbox-dark-soft` | Open Source Classics | Gruvbox dark soft | Orange `#fe8019` | +| `gruvbox-light-hard` | Open Source Classics | Gruvbox light hard | Orange `#af3a03` | +| `gruvbox-light-medium` | Open Source Classics | Gruvbox light medium | Orange `#af3a03` | +| `gruvbox-light-soft` | Open Source Classics | Gruvbox light soft | Orange `#af3a03` | **Light-theme gotcha**: The Hero and Fullscreen Player sit on top of album-art backgrounds with dark overlays. Their text colors are hardcoded white (not `var(--text-primary)`) so they stay readable in light themes (Latte, Nord Snowstorm). @@ -227,4 +244,4 @@ The workflow is split into three jobs: `create-release` (creates the GitHub Rele - **CoverLightbox**: Shared component (`src/components/CoverLightbox.tsx`). Props: `{ src, alt, onClose }`. ESC + overlay click to close. Used in `AlbumHeader` (album cover) and `ArtistDetail` (artist avatar, wrapped in `.artist-detail-avatar-btn`). - **Home page**: Section order: recent → discover → artist discovery (pill-buttons, no images) → starred → mostPlayed. Artist discovery uses `getArtists()` full list + client-side Fisher-Yates shuffle (16 random), rendered as `artist-ext-link` pill-buttons (same as ArtistDetail "Similar Artists") — no image loading, no performance impact. - **CoverLightbox + EQ popup**: Both use `createPortal(…, document.body)` to escape `backdrop-filter` CSS containing-block issues on the player bar and other ancestors. -- **Version**: 1.11.0 +- **Version**: 1.12.0 diff --git a/README.md b/README.md index 93c48a79..ab9b3ef5 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,12 @@ Designed specifically for users hosting their own music via Navidrome or other S ## ✨ Features -- 🎨 **Gorgeous UI**: 30 deeply integrated themes across 7 groups — Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Betriebssysteme (Cupertino, Aero Glass, Luna Teal), and Psysonic originals (WnAmp, Poison, Nucleo, Neon Drift…) — with smooth glassmorphism effects and micro-animations. +- 🎨 **Gorgeous UI**: 47 deeply integrated themes across 7 groups — Open Source Classics (Catppuccin, Nord, Gruvbox), Operating Systems, Games, Movies, Series, Psysonic originals, and Psysonic Mediaplayer — with smooth glassmorphism effects and micro-animations. - ⚡ **Blazing Fast**: Built with Rust & Tauri — native audio engine (rodio), minimal RAM usage compared to typical Electron apps. - 🌍 **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. +- 🎵 **Last.fm Integration**: Direct scrobbling, Now Playing updates, love/unlove, Similar Artists, and top stats — no Navidrome configuration required. +- 🎤 **Synchronized Lyrics**: In-sidebar lyrics pane powered by LRCLIB — synced with auto-scroll and line highlighting, plain-text fallback. - 💾 **IndexedDB Caching**: Ultra-fast loading times with persistent IndexedDB image caching for cover art and artist images. - 📀 **Album Downloads**: Support for downloading entire albums directly to your local machine. - 💿 **Album & Artist Views**: Beautiful grid displays and detailed artist pages with related albums and color-coded initial avatars for fast browsing. @@ -47,22 +48,19 @@ Designed specifically for users hosting their own music via Navidrome or other S - [x] Replay Gain (track + album mode) - [x] Gapless playback - [x] Waveform seekbar -- [x] Last.fm scrobbling, Now Playing & love/unlove *(beta)* +- [x] Last.fm scrobbling, Now Playing & love/unlove - [x] Similar Artists via Last.fm, filtered to library - [x] Statistics — Last.fm top charts & recent scrobbles +- [x] Synchronized lyrics via LRCLIB (in-sidebar, auto-scroll) - [x] Multi-server support - [x] IndexedDB image caching - [x] Random Mix with keyword filter & Super Genre mix -- [x] 35 themes across 8 groups: Catppuccin, Nord, Retro (Gruvbox), Tokyo Night, Operating Systems, Movies, Psysonic originals, Psysonic Mediaplayer +- [x] 47 themes across 7 groups: Open Source Classics, Operating Systems, Games, Movies, Series, Psysonic originals, Psysonic Mediaplayer - [x] Internationalization (English, German, French, Dutch) - [x] AUR package (Arch / CachyOS) - [x] Configurable keybindings - [x] Font picker (10 UI fonts) -### 🚧 In Progress -- [ ] **Last.fm integration** — stabilising, moving out of beta -- [ ] **Crossfade** — stability improvements - ### 📋 Planned - [ ] FLAC seeking fix - [ ] General UI polish & visual refinement diff --git a/package.json b/package.json index fecdbcc8..2e3713ef 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.11.0", + "version": "1.12.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index a08bd476..8ce03b3b 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Psychotoxic pkgname=psysonic -pkgver=1.11.0 +pkgver=1.12.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 6071fb0d..dd4f2660 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3139,7 +3139,7 @@ dependencies = [ [[package]] name = "psysonic" -version = "1.11.0" +version = "1.12.0" dependencies = [ "biquad", "md5", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 554b4f6c..3120f4ff 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.11.0" +version = "1.12.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 423a556b..872063bd 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -5,6 +5,7 @@ use std::time::{Duration, Instant}; use biquad::{Biquad, Coefficients, DirectForm2Transposed, ToHertz, Type as FilterType}; use rodio::{Decoder, Sink, Source}; +use rodio::source::UniformSourceIterator; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; @@ -209,7 +210,7 @@ impl> Iterator for EqualPowerFadeIn { (t * std::f32::consts::FRAC_PI_2).sin() }; self.sample_count += 1; - Some(sample * gain) + Some((sample * gain).clamp(-1.0, 1.0)) } } @@ -226,6 +227,82 @@ impl> Source for EqualPowerFadeIn { } } +// ─── TriggeredFadeOut — sample-level cos(t·π/2) fade-out triggered externally ─ +// +// Every track source is wrapped with this. It passes through at unity gain +// until `trigger` is set to true, at which point it reads `fade_total_samples` +// and applies a cos(t·π/2) envelope: +// gain(t) = cos(t · π/2), t ∈ [0, 1] +// At t = 0 gain = 1, at t = 1 gain = 0. +// After the fade completes, returns None to exhaust the source. +// +// Combined with EqualPowerFadeIn (sin curve) on Track B, this gives a +// symmetric constant-power crossfade: sin²+cos² = 1. + +struct TriggeredFadeOut> { + inner: S, + trigger: Arc, + fade_total_samples: Arc, + fade_progress: u64, + fading: bool, + cached_total: u64, +} + +impl> TriggeredFadeOut { + fn new(inner: S, trigger: Arc, fade_total_samples: Arc) -> Self { + Self { + inner, + trigger, + fade_total_samples, + fade_progress: 0, + fading: false, + cached_total: 0, + } + } +} + +impl> Iterator for TriggeredFadeOut { + type Item = f32; + fn next(&mut self) -> Option { + // Check trigger on first fade sample only (avoid atomic load per sample). + if !self.fading && self.trigger.load(Ordering::Relaxed) { + self.fading = true; + self.cached_total = self.fade_total_samples.load(Ordering::Relaxed).max(1); + self.fade_progress = 0; + } + + if self.fading { + if self.fade_progress >= self.cached_total { + // Fade complete — exhaust the source. + return None; + } + let sample = self.inner.next()?; + let t = self.fade_progress as f32 / self.cached_total as f32; + let gain = (t * std::f32::consts::FRAC_PI_2).cos(); + self.fade_progress += 1; + Some((sample * gain).clamp(-1.0, 1.0)) + } else { + self.inner.next() + } + } +} + +impl> Source for TriggeredFadeOut { + 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 back during a fade, cancel the fade. + if self.fading { + self.fading = false; + self.trigger.store(false, Ordering::Relaxed); + } + self.fade_progress = 0; + self.inner.try_seek(pos) + } +} + // ─── NotifyingSource — sets a flag when the inner iterator is exhausted ─────── // // This is the key mechanism for gapless: the progress task polls `done` to know @@ -269,6 +346,48 @@ impl> Source for NotifyingSource { } } +// ─── CountingSource — atomic sample counter for drift-free position tracking ─ +// +// Wraps the outermost source and increments a shared AtomicU64 on every sample. +// The progress task reads this counter and divides by (sample_rate * channels) +// to get the exact playback position — no wall-clock drift. + +struct CountingSource> { + inner: S, + counter: Arc, +} + +impl> CountingSource { + fn new(inner: S, counter: Arc) -> Self { + Self { inner, counter } + } +} + +impl> Iterator for CountingSource { + type Item = f32; + fn next(&mut self) -> Option { + let sample = self.inner.next(); + if sample.is_some() { + self.counter.fetch_add(1, Ordering::Relaxed); + } + sample + } +} + +impl> Source for CountingSource { + 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> { + // Reset counter to the sought position in samples. + let samples = (pos.as_secs_f64() * self.inner.sample_rate() as f64 + * self.inner.channels() as f64) as u64; + self.counter.store(samples, Ordering::Relaxed); + self.inner.try_seek(pos) + } +} + // ─── Encoder-gap trimming (iTunSMPB) ───────────────────────────────────────── // // MP3/AAC encoders prepend an "encoder delay" (typically 576–2112 silent @@ -326,12 +445,28 @@ fn parse_gapless_info(data: &[u8]) -> GaplessInfo { GaplessInfo { delay_samples: delay, total_valid_samples: total_valid } } -/// Build a fully-prepared playback source: decode → trim → EQ → fade-in → notify. +/// Result of build_source: the fully-wrapped source plus metadata and control Arcs. +struct BuiltSource { + source: CountingSource>>>>, + duration_secs: f64, + output_rate: u32, + output_channels: u16, + /// Trigger for the sample-level crossfade fade-out. + fadeout_trigger: Arc, + /// Total samples for the fade-out (set before triggering). + fadeout_samples: Arc, +} + +/// Build a fully-prepared playback source: +/// decode → trim → resample → EQ → fade-in → triggered-fade-out → notify → count /// /// `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 +/// +/// `sample_counter`: atomic counter incremented per sample for drift-free position. +/// `target_rate`: canonical output sample rate for resampling (0 = no resampling). fn build_source( data: Vec, duration_hint: f64, @@ -339,12 +474,15 @@ fn build_source( eq_enabled: Arc, done_flag: Arc, fade_in_dur: Duration, -) -> Result<(NotifyingSource>>, f64), String> { + sample_counter: Arc, + target_rate: u32, +) -> Result { 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(); + let channels = decoder.channels(); // Determine effective duration. // Prefer hint from Subsonic API (reliable) over decoder (unreliable for VBR MP3). @@ -356,7 +494,8 @@ fn build_source( .unwrap_or(duration_hint) }; - // Apply encoder-delay trim and optional end-padding trim. + // Apply encoder-delay trim and optional end-padding trim, + // then resample to the canonical target rate if needed. let dyn_src: DynSource = if gapless.delay_samples > 0 || gapless.total_valid_samples.is_some() { let delay_dur = Duration::from_secs_f64( gapless.delay_samples as f64 / sample_rate as f64 @@ -365,19 +504,47 @@ fn build_source( if let Some(total) = gapless.total_valid_samples { let valid_dur = Duration::from_secs_f64(total as f64 / sample_rate as f64); - DynSource::new(base.take_duration(valid_dur)) + let trimmed = base.take_duration(valid_dur); + if target_rate > 0 && sample_rate != target_rate { + DynSource::new(UniformSourceIterator::new(trimmed, channels, target_rate)) + } else { + DynSource::new(trimmed) + } } else { - DynSource::new(base) + if target_rate > 0 && sample_rate != target_rate { + DynSource::new(UniformSourceIterator::new(base, channels, target_rate)) + } else { + DynSource::new(base) + } } } else { - DynSource::new(decoder.convert_samples::()) + let converted = decoder.convert_samples::(); + if target_rate > 0 && sample_rate != target_rate { + DynSource::new(UniformSourceIterator::new(converted, channels, target_rate)) + } else { + DynSource::new(converted) + } }; + let output_rate = if target_rate > 0 && sample_rate != target_rate { target_rate } else { sample_rate }; + + let fadeout_trigger = Arc::new(AtomicBool::new(false)); + let fadeout_samples = Arc::new(AtomicU64::new(0)); + let eq_src = EqSource::new(dyn_src, eq_gains, eq_enabled); let fade_in = EqualPowerFadeIn::new(eq_src, fade_in_dur); - let notifying = NotifyingSource::new(fade_in, done_flag); + let fade_out = TriggeredFadeOut::new(fade_in, fadeout_trigger.clone(), fadeout_samples.clone()); + let notifying = NotifyingSource::new(fade_out, done_flag); + let counting = CountingSource::new(notifying, sample_counter); - Ok((notifying, effective_dur)) + Ok(BuiltSource { + source: counting, + duration_secs: effective_dur, + output_rate, + output_channels: channels, + fadeout_trigger, + fadeout_samples, + }) } // ─── Engine state ───────────────────────────────────────────────────────────── @@ -397,6 +564,9 @@ pub(crate) struct ChainedInfo { base_volume: f32, /// Set by NotifyingSource when this chained track's source is exhausted. source_done: Arc, + /// Atomic sample counter for this chained source (swapped into + /// samples_played on transition). + sample_counter: Arc, } pub struct AudioEngine { @@ -417,6 +587,16 @@ pub struct AudioEngine { /// Info about the next-up chained track (gapless mode). /// The progress task reads this when `current_source_done` fires. pub chained_info: Arc>>, + /// Atomic sample counter — incremented by CountingSource in the audio thread. + /// Progress task reads this for drift-free position tracking. + pub samples_played: Arc, + /// Sample rate of the currently playing source (for samples → seconds). + pub current_sample_rate: Arc, + /// Channel count of the currently playing source. + pub current_channels: Arc, + /// Instant (as nanos since UNIX epoch via Instant hack) of the last gapless + /// auto-advance. Commands arriving within 500 ms are rejected as ghost commands. + pub gapless_switch_at: Arc, } pub struct AudioCurrent { @@ -427,6 +607,10 @@ pub struct AudioCurrent { pub paused_at: Option, pub replay_gain_linear: f32, pub base_volume: f32, + /// Crossfade: trigger for sample-level fade-out of the current source. + pub fadeout_trigger: Option>, + /// Crossfade: total fade samples (set before triggering). + pub fadeout_samples: Option>, } impl AudioCurrent { @@ -459,6 +643,16 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { } } + // macOS: request a smaller CoreAudio buffer to reduce output latency. + // Smaller buffers = lower latency between decoded samples and DAC output, + // which tightens the gap between actual audio and UI event delivery. + #[cfg(target_os = "macos")] + { + if std::env::var("COREAUDIO_BUFFER_SIZE").is_err() { + std::env::set_var("COREAUDIO_BUFFER_SIZE", "512"); + } + } + let thread = std::thread::Builder::new() .name("psysonic-audio-stream".into()) .spawn(move || match rodio::OutputStream::try_default() { @@ -482,6 +676,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { paused_at: None, replay_gain_linear: 1.0, base_volume: 0.8, + fadeout_trigger: None, + fadeout_samples: None, })), generation: Arc::new(AtomicU64::new(0)), http_client: reqwest::Client::builder() @@ -496,6 +692,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { fading_out_sink: Arc::new(Mutex::new(None)), gapless_enabled: Arc::new(AtomicBool::new(false)), chained_info: Arc::new(Mutex::new(None)), + samples_played: Arc::new(AtomicU64::new(0)), + current_sample_rate: Arc::new(AtomicU32::new(44100)), + current_channels: Arc::new(AtomicU32::new(2)), + gapless_switch_at: Arc::new(AtomicU64::new(0)), }; (engine, thread) @@ -574,6 +774,24 @@ pub async fn audio_play( ) -> Result<(), String> { let gapless = state.gapless_enabled.load(Ordering::Relaxed); + // ── Ghost-command guard ─────────────────────────────────────────────────── + // After a gapless auto-advance, the frontend may fire a stale playTrack() + // call via IPC. If we're within 500 ms of the last gapless switch AND the + // requested URL matches the already-playing chained track, reject it. + { + let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst); + if switch_ms > 0 { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if now_ms.saturating_sub(switch_ms) < 500 { + // Within the guard window — suppress this ghost command. + return Ok(()); + } + } + } + // ── Gapless pre-chain hit ───────────────────────────────────────────────── // 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 @@ -640,16 +858,29 @@ pub async fn audio_play( Duration::from_millis(5) }; - // Build source: decode → trim → EQ → fade-in → notify. + // Build source: decode → trim → resample → EQ → fade-in → fade-out → notify → count. let done_flag = Arc::new(AtomicBool::new(false)); - let (source, duration_secs) = build_source( + // Reset sample counter for the new track. + state.samples_played.store(0, Ordering::Relaxed); + let target_rate = state.current_sample_rate.load(Ordering::Relaxed); + let built = build_source( data, duration_hint, state.eq_gains.clone(), state.eq_enabled.clone(), done_flag.clone(), fade_in_dur, + state.samples_played.clone(), + target_rate, ).map_err(|e| { app.emit("audio:error", &e).ok(); e })?; + let source = built.source; + let duration_secs = built.duration_secs; + let output_rate = built.output_rate; + let output_channels = built.output_channels; + + // Store the actual output rate/channels for position calculation. + state.current_sample_rate.store(output_rate, Ordering::Relaxed); + state.current_channels.store(output_channels as u32, Ordering::Relaxed); if state.generation.load(Ordering::SeqCst) != gen { return Ok(()); @@ -681,11 +912,12 @@ pub async fn audio_play( sink.append(source); - // Atomically swap sinks. - let (old_sink, old_vol) = { + // Atomically swap sinks — extract old sink + its fade-out trigger. + let (old_sink, old_fadeout_trigger, old_fadeout_samples) = { let mut cur = state.current.lock().unwrap(); - let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0); let old = cur.sink.take(); + let old_fo_trigger = cur.fadeout_trigger.take(); + let old_fo_samples = cur.fadeout_samples.take(); cur.sink = Some(sink); cur.duration_secs = duration_secs; cur.seek_offset = 0.0; @@ -693,39 +925,33 @@ pub async fn audio_play( cur.paused_at = None; cur.replay_gain_linear = gain_linear; cur.base_volume = volume.clamp(0.0, 1.0); - (old, old_vol) + cur.fadeout_trigger = Some(built.fadeout_trigger); + cur.fadeout_samples = Some(built.fadeout_samples); + (old, old_fo_trigger, old_fo_samples) }; - // Handle old sink: equal-power crossfade or immediate stop. + // Handle old sink: symmetric crossfade or immediate stop. if crossfade_enabled { if let Some(old) = old_sink { + // Trigger sample-level fade-out on Track A via TriggeredFadeOut. + // Calculate total fade samples from the measured actual_fade_secs. + let rate = state.current_sample_rate.load(Ordering::Relaxed); + let ch = state.current_channels.load(Ordering::Relaxed); + let fade_total = (actual_fade_secs as f64 * rate as f64 * ch as f64) as u64; + + if let (Some(trigger), Some(samples)) = (old_fadeout_trigger, old_fadeout_samples) { + samples.store(fade_total.max(1), Ordering::SeqCst); + trigger.store(true, Ordering::SeqCst); + } + + // Keep old sink alive until the fade completes + small margin, + // then drop it. No volume stepping needed — the fade-out runs + // at sample level inside the audio thread. *state.fading_out_sink.lock().unwrap() = Some(old); let fo_arc = state.fading_out_sink.clone(); + let cleanup_dur = Duration::from_secs_f32(actual_fade_secs + 0.5); tokio::spawn(async move { - // ~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) => { - // Equal-power cos curve: gain_a = cos(t · π/2) - // t goes 1→0 as i goes total_steps→0 - let t = i as f32 / total_steps as f32; - let gain = (t * std::f32::consts::FRAC_PI_2).cos(); - s.set_volume(old_vol * gain); - true - } - None => false, - } - // MutexGuard dropped here before the await - }; - if !alive { return; } - tokio::time::sleep(Duration::from_millis(STEP_MS)).await; - } + tokio::time::sleep(cleanup_dur).await; if let Some(s) = fo_arc.lock().unwrap().take() { s.stop(); } @@ -747,6 +973,10 @@ pub async fn audio_play( state.crossfade_secs.clone(), done_flag, app, + state.samples_played.clone(), + state.current_sample_rate.clone(), + state.current_channels.clone(), + state.gapless_switch_at.clone(), ); Ok(()) @@ -815,14 +1045,22 @@ pub async fn audio_chain_preload( let (gain_linear, effective_volume) = compute_gain(replay_gain_db, replay_gain_peak, volume); let done_next = Arc::new(AtomicBool::new(false)); - let (source, duration_secs) = build_source( + // Use a dedicated counter for the chained source — it will be swapped into + // samples_played when the chained track becomes active. + let chain_counter = Arc::new(AtomicU64::new(0)); + let target_rate = state.current_sample_rate.load(Ordering::Relaxed); + let built = build_source( data, duration_hint, state.eq_gains.clone(), state.eq_enabled.clone(), done_next.clone(), Duration::ZERO, // gapless: no fade-in — sample-accurate boundary, no click + chain_counter.clone(), + target_rate, ).map_err(|e| e.to_string())?; + let source = built.source; + let duration_secs = built.duration_secs; // Final gen check — reject if a manual skip happened during decode. if state.generation.load(Ordering::SeqCst) != snapshot_gen { @@ -847,6 +1085,7 @@ pub async fn audio_chain_preload( replay_gain_linear: gain_linear, base_volume: volume.clamp(0.0, 1.0), source_done: done_next, + sample_counter: chain_counter, }); Ok(()) @@ -859,6 +1098,12 @@ pub async fn audio_chain_preload( /// done flag is set AND `chained_info` has data, it swaps `done` to the /// chained source's flag and transitions state — all without creating a new /// task or changing the generation counter. +/// +/// Key changes from the previous implementation: +/// • 100 ms tick (was 500 ms) — halves worst-case event latency +/// • Position from atomic sample counter (no wall-clock drift) +/// • Immediate `audio:track_switched` event at decoder boundary +/// • `audio:ended` only fires when no chained successor exists fn spawn_progress_task( gen: u64, gen_counter: Arc, @@ -868,6 +1113,10 @@ fn spawn_progress_task( crossfade_secs_arc: Arc, initial_done: Arc, app: AppHandle, + samples_played: Arc, + sample_rate_arc: Arc, + channels_arc: Arc, + gapless_switch_at: Arc, ) { tokio::spawn(async move { let mut near_end_ticks: u32 = 0; @@ -875,7 +1124,8 @@ fn spawn_progress_task( let mut current_done = initial_done; loop { - tokio::time::sleep(Duration::from_millis(500)).await; + // 100 ms tick — tight enough for responsive UI, low enough CPU cost. + tokio::time::sleep(Duration::from_millis(100)).await; if gen_counter.load(Ordering::SeqCst) != gen { break; @@ -884,24 +1134,40 @@ fn spawn_progress_task( // ── 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. + // audio:track_switched 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. + + // Swap the sample counter: the chained source's counter + // is already being incremented by CountingSource. Copy its + // current value into the shared samples_played so the + // progress calculation stays accurate. + let chained_samples = info.sample_counter.load(Ordering::Relaxed); + samples_played.store(chained_samples, Ordering::Relaxed); + + // Update tracking state. { let mut cur = current_arc.lock().unwrap(); 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.duration_secs = info.duration_secs; cur.seek_offset = 0.0; cur.play_started = Some(Instant::now()); } - app.emit("audio:playing", info.duration_secs).ok(); + + // Record the gapless switch timestamp for ghost-command guard. + let switch_ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + gapless_switch_at.store(switch_ts, Ordering::SeqCst); + + // Emit the new track_switched event — this is immediate, + // not delayed by 500 ms like the old audio:playing was. + app.emit("audio:track_switched", info.duration_secs).ok(); near_end_ticks = 0; continue; } @@ -910,9 +1176,26 @@ fn spawn_progress_task( // the near-end logic below. } - let (pos, dur, is_paused) = { + // ── Position from atomic sample counter ────────────────────────── + let rate = sample_rate_arc.load(Ordering::Relaxed) as f64; + let ch = channels_arc.load(Ordering::Relaxed) as f64; + let samples = samples_played.load(Ordering::Relaxed) as f64; + let divisor = (rate * ch).max(1.0); + + let dur = { let cur = current_arc.lock().unwrap(); - (cur.position(), cur.duration_secs, cur.paused_at.is_some()) + cur.duration_secs + }; + let is_paused = { + let cur = current_arc.lock().unwrap(); + cur.paused_at.is_some() + }; + + let pos = if is_paused { + let cur = current_arc.lock().unwrap(); + cur.paused_at.unwrap_or(0.0) + } else { + (samples / divisor).min(dur.max(0.001)) }; app.emit("audio:progress", ProgressPayload { current_time: pos, duration: dur }).ok(); @@ -927,7 +1210,8 @@ fn spawn_progress_task( if dur > end_threshold && pos >= dur - end_threshold { near_end_ticks += 1; - if near_end_ticks >= 2 { + // At 100 ms ticks, 10 ticks ≈ 1 s — equivalent to the old 2×500ms. + if near_end_ticks >= 10 { gen_counter.fetch_add(1, Ordering::SeqCst); app.emit("audio:ended", ()).ok(); break; @@ -982,6 +1266,20 @@ pub fn audio_stop(state: State<'_, AudioEngine>) { #[tauri::command] pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), String> { + // Ghost-command guard: reject seeks within 500 ms of a gapless auto-advance. + { + let switch_ms = state.gapless_switch_at.load(Ordering::SeqCst); + if switch_ms > 0 { + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + if now_ms.saturating_sub(switch_ms) < 500 { + return Ok(()); + } + } + } + // Seeking far back invalidates any pending gapless chain. let cur_pos = { let cur = state.current.lock().unwrap(); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7a6fa514..44a36e33 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.11.0", + "version": "1.12.0", "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", @@ -23,7 +23,8 @@ "decorations": true, "transparent": false, "visible": true, - "dragDropEnabled": false + "dragDropEnabled": false, + "devtools": false } ], "security": { diff --git a/src/api/lrclib.ts b/src/api/lrclib.ts new file mode 100644 index 00000000..10301c25 --- /dev/null +++ b/src/api/lrclib.ts @@ -0,0 +1,47 @@ +export interface LrclibLyrics { + syncedLyrics: string | null; + plainLyrics: string | null; +} + +export interface LrcLine { + time: number; // seconds + text: string; +} + +export async function fetchLyrics( + artist: string, + title: string, + album: string, + duration: number, +): Promise { + const params = new URLSearchParams({ + artist_name: artist, + track_name: title, + album_name: album, + duration: Math.round(duration).toString(), + }); + try { + const res = await fetch(`https://lrclib.net/api/get?${params}`); + if (!res.ok) return null; + const data = await res.json(); + return { + syncedLyrics: data.syncedLyrics ?? null, + plainLyrics: data.plainLyrics ?? null, + }; + } catch { + return null; + } +} + +export function parseLrc(lrc: string): LrcLine[] { + const lines: LrcLine[] = []; + for (const line of lrc.split('\n')) { + const match = line.match(/^\[(\d+):(\d+\.\d+)\](.*)/); + if (!match) continue; + const mins = parseInt(match[1], 10); + const secs = parseFloat(match[2]); + const text = match[3].trim(); + lines.push({ time: mins * 60 + secs, text }); + } + return lines.sort((a, b) => a.time - b.time); +} diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 3de63458..8b76f630 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -191,7 +191,7 @@ export default function AlbumHeader({ id="album-star-btn" onClick={onToggleStar} data-tooltip={isStarred ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} - style={{ color: isStarred ? 'var(--accent)' : 'inherit', border: isStarred ? '1px solid var(--accent)' : undefined }} + style={{ color: isStarred ? 'var(--color-star-active, var(--accent))' : 'inherit', border: isStarred ? '1px solid var(--color-star-active, var(--accent))' : undefined }} > {t('albumDetail.favorite')} diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index 8a3533a5..b51108c6 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -156,7 +156,7 @@ export default function AlbumTrackList({ className="btn btn-ghost track-star-btn" onClick={e => onToggleSongStar(song, e)} data-tooltip={starredSongs.has(song.id) ? t('albumDetail.favoriteRemove') : t('albumDetail.favoriteAdd')} - style={{ color: starredSongs.has(song.id) ? 'var(--accent)' : 'var(--text-muted)' }} + style={{ color: starredSongs.has(song.id) ? 'var(--color-star-active, var(--accent))' : 'var(--color-star-inactive, var(--text-muted))' }} > diff --git a/src/components/FullscreenPlayer.tsx b/src/components/FullscreenPlayer.tsx index 8765212e..ffcb3f05 100644 --- a/src/components/FullscreenPlayer.tsx +++ b/src/components/FullscreenPlayer.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useState, useRef, memo } from 'react'; import { Play, Pause, SkipBack, SkipForward, - ChevronDown, Repeat, Repeat1, Square, Music + ChevronDown, Repeat, Repeat1, Square, Music, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; +import { useLyricsStore } from '../store/lyricsStore'; import { buildCoverArtUrl, coverArtCacheKey, getArtistInfo } from '../api/subsonic'; import CachedImage, { useCachedUrl } from './CachedImage'; import { useTranslation } from 'react-i18next'; @@ -147,6 +148,11 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { const stop = usePlayerStore(s => s.stop); const toggleRepeat = usePlayerStore(s => s.toggleRepeat); + const showLyrics = useLyricsStore(s => s.showLyrics); + const activeTab = useLyricsStore(s => s.activeTab); + const isQueueVisible = usePlayerStore(s => s.isQueueVisible); + const toggleQueue = usePlayerStore(s => s.toggleQueue); + const duration = currentTrack?.duration ?? 0; const coverUrl = currentTrack?.coverArt ? buildCoverArtUrl(currentTrack.coverArt, 800) : ''; const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : ''; @@ -241,6 +247,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) { > {repeatMode === 'one' ? : } + diff --git a/src/components/LyricsPane.tsx b/src/components/LyricsPane.tsx new file mode 100644 index 00000000..f6304b76 --- /dev/null +++ b/src/components/LyricsPane.tsx @@ -0,0 +1,109 @@ +import { useEffect, useRef, useState } from 'react'; +import { usePlayerStore } from '../store/playerStore'; +import { fetchLyrics, parseLrc, LrcLine } from '../api/lrclib'; +import { useTranslation } from 'react-i18next'; +import type { Track } from '../store/playerStore'; + +interface Props { + currentTrack: Track | null; +} + +export default function LyricsPane({ currentTrack }: Props) { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(false); + const [syncedLines, setSyncedLines] = useState(null); + const [plainLyrics, setPlainLyrics] = useState(null); + const [notFound, setNotFound] = useState(false); + const [fetchedFor, setFetchedFor] = useState(null); + + const hasSynced = syncedLines !== null && syncedLines.length > 0; + const currentTime = usePlayerStore(s => hasSynced ? s.currentTime : 0); + + const lineRefs = useRef<(HTMLDivElement | null)[]>([]); + const prevActive = useRef(-1); + + useEffect(() => { + if (!currentTrack || currentTrack.id === fetchedFor) return; + let cancelled = false; + setSyncedLines(null); + setPlainLyrics(null); + setNotFound(false); + setLoading(true); + lineRefs.current = []; + prevActive.current = -1; + + fetchLyrics( + currentTrack.artist ?? '', + currentTrack.title, + currentTrack.album ?? '', + currentTrack.duration ?? 0, + ).then(result => { + if (cancelled) return; + setLoading(false); + setFetchedFor(currentTrack.id); + if (!result || (!result.syncedLyrics && !result.plainLyrics)) { + setNotFound(true); + return; + } + if (result.syncedLyrics) { + const lines = parseLrc(result.syncedLyrics); + setSyncedLines(lines.length > 0 ? lines : null); + } + setPlainLyrics(result.plainLyrics); + }).catch(() => { + if (!cancelled) { setLoading(false); setNotFound(true); } + }); + return () => { cancelled = true; }; + }, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reset when track changes + useEffect(() => { + setFetchedFor(null); + }, [currentTrack?.id]); + + const activeIdx = hasSynced + ? syncedLines!.reduce((acc, line, i) => (currentTime >= line.time ? i : acc), -1) + : -1; + + useEffect(() => { + if (activeIdx < 0 || activeIdx === prevActive.current) return; + prevActive.current = activeIdx; + lineRefs.current[activeIdx]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, [activeIdx]); + + if (!currentTrack) { + return ( +
+

{t('player.lyricsNotFound')}

+
+ ); + } + + return ( +
+ {loading &&

{t('player.lyricsLoading')}

} + {notFound && !loading &&

{t('player.lyricsNotFound')}

} + {hasSynced && ( +
+ {syncedLines!.map((line, i) => ( +
{ lineRefs.current[i] = el; }} + className={`lyrics-line${i === activeIdx ? ' active' : ''}`} + > + {line.text || '\u00A0'} +
+ ))} +
+ )} + {!hasSynced && plainLyrics && ( +
+ {plainLyrics.split('\n').map((line, i) => ( +

{line || '\u00A0'}

+ ))} +
+ )} +
+ ); +} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index d7f49b6c..e44da4bb 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart + Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X, Heart, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; @@ -12,6 +12,7 @@ import WaveformSeek from './WaveformSeek'; import Equalizer from './Equalizer'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; +import { useLyricsStore } from '../store/lyricsStore'; function formatTime(seconds: number): string { if (!seconds || isNaN(seconds)) return '0:00'; @@ -24,11 +25,14 @@ export default function PlayerBar() { const { t } = useTranslation(); const navigate = useNavigate(); const [eqOpen, setEqOpen] = useState(false); + const showLyrics = useLyricsStore(s => s.showLyrics); + const activeTab = useLyricsStore(s => s.activeTab); const { currentTrack, isPlaying, currentTime, volume, togglePlay, next, previous, setVolume, stop, toggleRepeat, repeatMode, toggleFullscreen, lastfmLoved, toggleLastfmLove, + isQueueVisible, toggleQueue, } = usePlayerStore(); const { lastfmSessionKey } = useAuthStore(); @@ -115,7 +119,7 @@ export default function PlayerBar() { aria-label={isPlaying ? t('player.pause') : t('player.play')} data-tooltip={isPlaying ? t('player.pause') : t('player.play')} > - {isPlaying ? : } + {isPlaying ? : } + {/* EQ Button */} @@ -472,6 +478,28 @@ export default function QueuePanel() { }) )} + ) : ( + + )} + +
+ + +
{saveModalOpen && ( g.themes.some(t => t.id === value))?.group ?? THEME_GROUPS[0].group; + const [openGroup, setOpenGroup] = useState(initialOpen); + + const toggle = (group: string) => setOpenGroup(prev => prev === group ? null : group); + return ( -
- {THEME_GROUPS.map(({ group, themes }) => ( -
-
- {group} +
+ {THEME_GROUPS.map(({ group, themes }) => { + const isOpen = openGroup === group; + const hasActive = themes.some(t => t.id === value); + return ( +
+ + {isOpen && ( +
+
+ {themes.map((t) => { + const isActive = value === t.id; + return ( + + ); + })} +
+
+ )}
-
- {themes.map((t) => { - const isActive = value === t.id; - return ( - - ); - })} -
-
- ))} + ); + })}
); } diff --git a/src/i18n.ts b/src/i18n.ts index 8d3fbb7c..131a746b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -525,7 +525,10 @@ const enTranslation = { repeatOne: 'One', progress: 'Song Progress', volume: 'Volume', - toggleQueue: 'Toggle Queue' + toggleQueue: 'Toggle Queue', + lyrics: 'Lyrics', + lyricsLoading: 'Loading lyrics…', + lyricsNotFound: 'No lyrics found for this track', } }; @@ -1053,7 +1056,10 @@ const deTranslation = { repeatOne: 'Einen', progress: 'Songfortschritt', volume: 'Lautstärke', - toggleQueue: 'Warteschlange umschalten' + toggleQueue: 'Warteschlange umschalten', + lyrics: 'Lyrics', + lyricsLoading: 'Lyrics werden geladen…', + lyricsNotFound: 'Keine Lyrics für diesen Titel gefunden', } }; @@ -1581,7 +1587,10 @@ const frTranslation = { repeatOne: 'Un', progress: 'Progression', volume: 'Volume', - toggleQueue: 'Afficher/masquer la file' + toggleQueue: 'Afficher/masquer la file', + lyrics: 'Paroles', + lyricsLoading: 'Chargement des paroles…', + lyricsNotFound: 'Aucune parole trouvée pour ce titre', } }; @@ -2109,7 +2118,10 @@ const nlTranslation = { repeatOne: 'Één', progress: 'Nummervoortgang', volume: 'Volume', - toggleQueue: 'Wachtrij in-/uitschakelen' + toggleQueue: 'Wachtrij in-/uitschakelen', + lyrics: 'Songtekst', + lyricsLoading: 'Songtekst laden…', + lyricsNotFound: 'Geen songtekst gevonden voor dit nummer', } }; diff --git a/src/pages/NowPlaying.tsx b/src/pages/NowPlaying.tsx index 451f40f0..7cf81be5 100644 --- a/src/pages/NowPlaying.tsx +++ b/src/pages/NowPlaying.tsx @@ -1,8 +1,9 @@ import React, { useState, useRef, useEffect, useCallback, memo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { Music, Star, ExternalLink } from 'lucide-react'; +import { Music, Star, ExternalLink, MicVocal } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; +import { useLyricsStore } from '../store/lyricsStore'; import { buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, getAlbum, getArtistInfo, @@ -123,12 +124,12 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) { const getTagStyle = (name: string, idx: number): React.CSSProperties => { const h = strHash(name); - const sizePool = [12, 13, 14, 15, 16, 17, 18, 20, 22]; + const sizePool = [10, 11, 12, 13, 14, 15, 16]; const size = sizePool[(h + idx * 7) % sizePool.length]; - const weight = size >= 19 ? 700 : size >= 16 ? 500 : 400; - const pad = size >= 18 ? '7px 15px' : size >= 15 ? '6px 12px' : '5px 10px'; + const weight = size >= 15 ? 600 : size >= 13 ? 500 : 400; + const pad = size >= 15 ? '5px 10px' : '4px 8px'; const opacity = 0.6 + ((h % 5) * 0.08); - const verticals = [-10, -6, -3, 0, 4, 7, 10, -8, 3, -4, 8, -1, 5, -7, 2]; + const verticals = [-5, -3, -1, 0, 2, 4, 5, -4, 2, -2, 4, 0, 3, -3, 1]; const ty = verticals[(h + idx * 4) % verticals.length]; return { fontSize: `${size}px`, fontWeight: weight, padding: pad, opacity, transform: `translateY(${ty}px)` }; }; @@ -137,7 +138,7 @@ function TagCloud({ similarArtists, onArtistClick }: TagCloudProps) {
{t('artistDetail.similarArtists')}
{([similarArtists.slice(0, 3), similarArtists.slice(3, 6)] as const).map((row, rowIdx) => ( -
+
{row.map((a, i) => ( s.currentTrack); const isPlaying = usePlayerStore(s => s.isPlaying); + const showLyrics = useLyricsStore(s => s.showLyrics); + const activeTab = useLyricsStore(s => s.activeTab); + const isQueueVisible = usePlayerStore(s => s.isQueueVisible); + const toggleQueue = usePlayerStore(s => s.toggleQueue); const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); @@ -356,6 +361,14 @@ export default function NowPlaying() { > +
diff --git a/src/store/lyricsStore.ts b/src/store/lyricsStore.ts new file mode 100644 index 00000000..8793f7ad --- /dev/null +++ b/src/store/lyricsStore.ts @@ -0,0 +1,17 @@ +import { create } from 'zustand'; + +type SidebarTab = 'queue' | 'lyrics'; + +interface LyricsState { + activeTab: SidebarTab; + setTab: (tab: SidebarTab) => void; + showLyrics: () => void; + showQueue: () => void; +} + +export const useLyricsStore = create()((set) => ({ + activeTab: 'queue', + setTab: (tab) => set({ activeTab: tab }), + showLyrics: () => set({ activeTab: 'lyrics' }), + showQueue: () => set({ activeTab: 'queue' }), +})); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index ccb2df7b..588dee80 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -127,6 +127,10 @@ let seekDebounce: ReturnType | null = null; // to the Rust backend before it has finished the previous one. let togglePlayLock = false; +// Timestamp of the last gapless auto-advance (from audio:track_switched). +// Used to suppress ghost-commands from stale IPC arriving after the switch. +let lastGaplessSwitchTime = 0; + // ─── Server queue sync ───────────────────────────────────────────────────────── let syncTimeout: ReturnType | null = null; function syncQueueToServer(queue: Track[], currentTrack: Track | null, currentTime: number) { @@ -204,6 +208,12 @@ function handleAudioProgress(current_time: number, duration: number) { } function handleAudioEnded() { + // If a gapless switch happened recently, this ended event is stale — the + // progress task fired it for the OLD source before seeing the chained one. + if (Date.now() - lastGaplessSwitchTime < 600) { + return; + } + const { repeatMode, currentTrack, queue } = usePlayerStore.getState(); isAudioPaused = false; usePlayerStore.setState({ isPlaying: false, progress: 0, currentTime: 0, buffered: 0 }); @@ -216,6 +226,61 @@ function handleAudioEnded() { }, 150); } +/** + * Handle gapless auto-advance: the Rust engine has already switched to the + * next source sample-accurately. We just need to update the UI state without + * touching the audio stream (no playTrack() call!). + */ +function handleAudioTrackSwitched(duration: number) { + lastGaplessSwitchTime = Date.now(); + isAudioPaused = false; + + const store = usePlayerStore.getState(); + const { queue, queueIndex, repeatMode } = store; + const nextIdx = queueIndex + 1; + let nextTrack: Track | null = null; + let newIndex = queueIndex; + + if (repeatMode === 'one' && store.currentTrack) { + nextTrack = store.currentTrack; + // queueIndex stays the same + } else if (nextIdx < queue.length) { + nextTrack = queue[nextIdx]; + newIndex = nextIdx; + } else if (repeatMode === 'all' && queue.length > 0) { + nextTrack = queue[0]; + newIndex = 0; + } + + if (!nextTrack) return; + + usePlayerStore.setState({ + currentTrack: nextTrack, + queueIndex: newIndex, + isPlaying: true, + progress: 0, + currentTime: 0, + buffered: 0, + scrobbled: false, + lastfmLoved: false, + }); + + // Report Now Playing to Navidrome + Last.fm + reportNowPlaying(nextTrack.id); + const { scrobblingEnabled, lastfmSessionKey } = useAuthStore.getState(); + if (lastfmSessionKey) { + if (scrobblingEnabled) lastfmUpdateNowPlaying(nextTrack, lastfmSessionKey); + lastfmGetTrackLoved(nextTrack.title, nextTrack.artist, lastfmSessionKey).then(loved => { + const cacheKey = `${nextTrack!.title}::${nextTrack!.artist}`; + usePlayerStore.setState(s => ({ + lastfmLoved: loved, + lastfmLovedCache: { ...s.lastfmLovedCache, [cacheKey]: loved }, + })); + }); + } + syncQueueToServer(queue, nextTrack, 0); +} + function handleAudioError(message: string) { console.error('[psysonic] Audio error from backend:', message); isAudioPaused = false; @@ -241,6 +306,7 @@ export function initAudioListeners(): () => void { ), listen('audio:ended', () => handleAudioEnded()), listen('audio:error', ({ payload }) => handleAudioError(payload)), + listen('audio:track_switched', ({ payload }) => handleAudioTrackSwitched(payload)), ]; // Sync Last.fm loved tracks cache on startup. @@ -364,6 +430,12 @@ export const usePlayerStore = create()( // ── playTrack ──────────────────────────────────────────────────────────── playTrack: (track, queue) => { + // Ghost-command guard: if a gapless switch happened within 500 ms, + // this playTrack call is likely a stale IPC echo — suppress it. + if (Date.now() - lastGaplessSwitchTime < 500) { + return; + } + const gen = ++playGeneration; isAudioPaused = false; if (seekDebounce) { clearTimeout(seekDebounce); seekDebounce = null; } diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index d1371773..b25a42c1 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' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'tokyo-night' | 'tokyo-night-storm' | 'tokyo-night-light' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade'; +type Theme = 'mocha' | 'macchiato' | 'frappe' | 'latte' | 'nord' | 'nord-snowstorm' | 'nord-frost' | 'nord-aurora' | 'psychowave' | 'wnamp' | 'poison' | 'nucleo' | 'navy-jukebox' | 'cobalt-media' | 'onyx-cinema' | 'vintage-tube-radio' | 'neon-drift' | 'aero-glass' | 'luna-teal' | 'w98' | 'cupertino-light' | 'cupertino-dark' | 'gruvbox-dark-hard' | 'gruvbox-dark-medium' | 'gruvbox-dark-soft' | 'gruvbox-light-hard' | 'gruvbox-light-medium' | 'gruvbox-light-soft' | 'spotless' | 'dzr0' | 'cupertino-beats' | 'lambda-17' | 'azerothian-gold' | 'ascalon' | 'grand-theft-audio' | 'v-tactical' | 'nightcity-2077' | 'middle-earth' | 'morpheus' | 'pandora' | 'stark-hud' | 'blade' | 'imperial-sith' | 'order-of-the-phoenix' | 'heisenberg' | 'ice-and-fire' | 'doh-matic'; interface ThemeState { theme: Theme; diff --git a/src/styles/components.css b/src/styles/components.css index 9479be3e..27510b43 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -935,6 +935,7 @@ border-radius: var(--radius-md); cursor: pointer; transition: background var(--transition-fast); + user-select: none; } .track-row.track-row-va { @@ -1214,6 +1215,7 @@ font-size: 14px; line-height: 1.7; color: var(--text-secondary); + user-select: text; } .artist-bio a { @@ -1301,6 +1303,7 @@ line-height: 1.75; color: var(--text-secondary); max-width: 100%; + user-select: text; } .artist-bio-text a { @@ -1487,6 +1490,170 @@ overflow: hidden; } +/* ── Theme Accordion ─────────────────────────────────────────────────────── */ + +.theme-accordion { + display: flex; + flex-direction: column; +} + +.theme-accordion-item { + border-bottom: 1px solid var(--border-subtle); +} + +.theme-accordion-item:last-child { + border-bottom: none; +} + +.theme-accordion-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + width: 100%; + padding: 10px var(--space-4); + text-align: left; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.07em; + color: var(--text-muted); + background: var(--bg-card); + border-left: 3px solid transparent; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); +} + +.theme-accordion-header:hover { + background: var(--bg-hover); + color: var(--text-secondary); +} + +.theme-accordion-open .theme-accordion-header { + color: var(--accent); + background: var(--bg-hover); + border-left: 3px solid var(--accent); + padding-left: calc(var(--space-5) + 3px); +} + +.theme-accordion-chevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.theme-accordion-open .theme-accordion-chevron { + transform: rotate(180deg); + color: var(--accent); +} + +.theme-accordion-content { + padding: 14px var(--space-4) var(--space-4) calc(var(--space-4) + 3px); + background: var(--bg-app); + border-top: 1px solid var(--border-subtle); + border-left: 3px solid var(--accent); +} + +.theme-accordion-active-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + margin-left: 8px; + vertical-align: middle; + opacity: 0.8; +} + +/* ── Queue Tab Bar ────────────────────────────────────────────────────────── */ + +.queue-tab-bar { + display: flex; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.queue-tab-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 10px 8px; + background: none; + border: none; + cursor: pointer; + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + transition: color 0.15s, background 0.15s; +} + +.queue-tab-btn:hover { color: var(--text-primary); background: var(--bg-hover); } +.queue-tab-btn.active { color: var(--accent); } + +/* ── Lyrics Pane (sidebar) ────────────────────────────────────────────────── */ + +.lyrics-pane { + flex: 1; + overflow-y: auto; + padding: 16px 16px 8px; + scroll-behavior: smooth; +} + +.lyrics-pane-empty { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.lyrics-status { + text-align: center; + color: var(--text-muted); + font-size: 13px; + padding: 40px 0; +} + +.lyrics-synced { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 0 16px; +} + +.lyrics-line { + font-size: 14px; + font-weight: 400; + color: var(--text-muted); + text-align: center; + padding: 5px 8px; + border-radius: 6px; + line-height: 1.6; + transition: color 0.25s, font-size 0.2s, font-weight 0.2s; + cursor: default; +} + +.lyrics-line.active { + color: var(--text-primary); + font-size: 15px; + font-weight: 600; +} + +.lyrics-plain { + padding: 4px 0 16px; +} + +.lyrics-plain-line { + font-size: 13px; + line-height: 1.85; + color: var(--text-secondary); + text-align: center; + margin: 0; +} + + +/* ── Help ─────────────────────────────────────────────────────────────────── */ + .help-item { border-bottom: 1px solid var(--border-subtle); } @@ -2334,6 +2501,7 @@ padding: 6px var(--space-3); border-radius: var(--radius-md); transition: background 0.15s; + user-select: none; } .playlist-row:hover { @@ -2814,6 +2982,7 @@ font-size: 13px; line-height: 1.65; color: rgba(255, 255, 255, 0.72); + user-select: text; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; @@ -3328,6 +3497,7 @@ border-radius: var(--radius-md); cursor: pointer; transition: background 0.12s; + user-select: none; } .np-queue-item:hover, diff --git a/src/styles/theme.css b/src/styles/theme.css index 99b901b7..62f86a6a 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -1053,161 +1053,1038 @@ --danger: #9d0006; } -/* ─── Tokyo Night ─── */ -[data-theme='tokyo-night'] { +/* ─── Lambda 17 ─── */ +[data-theme='lambda-17'] { 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"); + --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%23ff9d00%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; + --ctp-crust: #0a0b0c; + --ctp-mantle: #101214; + --ctp-base: #14171a; + --ctp-surface0: #1c1f22; + --ctp-surface1: #25292d; + --ctp-surface2: #2d3237; + --ctp-overlay0: #4a4f54; + --ctp-overlay1: #6b7279; + --ctp-overlay2: #8c959d; + --ctp-text: #ffb000; + --ctp-subtext1: #e09000; + --ctp-subtext0: #a0a5a9; + --ctp-mauve: #ff9d00; + --ctp-lavender: #ffb84d; + --ctp-pink: #ff5555; + --ctp-flamingo: #ff6e6e; + --ctp-rosewater:#ffb000; + --ctp-green: #50fa7b; + --ctp-teal: #33cccc; + --ctp-sky: #8be9fd; + --ctp-blue: #33cccc; + --ctp-sapphire: #33cccc; + --ctp-yellow: #f1fa8c; + --ctp-peach: #ffb000; + --ctp-maroon: #ff5555; + --ctp-red: #ff5555; - --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; + --bg-app: #14171a; + --bg-sidebar: #101214; + --bg-card: #1c1f22; + --bg-hover: rgba(255, 157, 0, 0.1); + --bg-player: #0a0b0c; + --bg-glass: rgba(20, 23, 26, 0.85); + --accent: #ff9d00; + --accent-dim: rgba(255, 157, 0, 0.15); + --accent-glow: rgba(255, 157, 0, 0.4); + --text-primary: #ffb000; + --text-secondary:#e09000; + --text-muted: #6b7279; + --border: #2d3237; + --border-subtle: #1c1f22; + --border-dropdown: #2d3237; + --shadow-dropdown: rgba(0, 0, 0, 0.7); + --positive: #50fa7b; + --warning: #f1fa8c; + --danger: #ff5555; + --radius-sm: 0px; + --radius-md: 2px; + --radius-lg: 4px; } -/* ─── 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; +[data-theme='lambda-17'] .app-shell { + background-image: + linear-gradient(rgba(18, 16, 16, 0) 50%, rgba(0, 0, 0, 0.15) 50%), + radial-gradient(rgba(255, 157, 0, 0.05) 0%, transparent 80%); + background-size: 100% 4px, 100% 100%; } -/* ─── Tokyo Night Light ─── */ -[data-theme='tokyo-night-light'] { +[data-theme='lambda-17'] .sidebar { + border-right: 1px solid var(--accent); + box-shadow: inset -10px 0 20px -15px var(--accent-glow); +} + +[data-theme='lambda-17'] .player-bar { + border-top: 2px solid var(--accent); +} + +[data-theme='lambda-17'] .player-track-name { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + text-shadow: 0 0 8px var(--accent-glow); + letter-spacing: 0.5px; +} + +[data-theme='lambda-17'] .nav-link.active { + background: var(--accent-dim); + border-left: 3px solid var(--accent); + color: var(--accent); +} + +/* ─── Azerothian Gold ─── */ +[data-theme='azerothian-gold'] { + 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%23c19e67%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: #0a0a0a; + --ctp-mantle: #121212; + --ctp-base: #1a1a1a; + --ctp-surface0: #25221e; + --ctp-surface1: #363028; + --ctp-surface2: #4d4538; + --ctp-overlay0: #6b6254; + --ctp-overlay1: #8c8170; + --ctp-overlay2: #a69884; + --ctp-text: #e6dbcc; + --ctp-subtext1: #c19e67; + --ctp-subtext0: #a68a56; + --ctp-mauve: #f5d45c; + --ctp-lavender: #ffe485; + --ctp-pink: #e74c3c; + --ctp-flamingo: #e74c3c; + --ctp-rosewater:#e6dbcc; + --ctp-green: #2ecc71; + --ctp-teal: #3498db; + --ctp-sky: #3498db; + --ctp-blue: #3498db; + --ctp-sapphire: #3498db; + --ctp-yellow: #f1c40f; + --ctp-peach: #c19e67; + --ctp-maroon: #e74c3c; + --ctp-red: #e74c3c; + + --bg-app: #1a1a1a; + --bg-sidebar: #121212; + --bg-card: #25221e; + --bg-hover: rgba(193, 158, 103, 0.1); + --bg-player: #0a0a0a; + --bg-glass: rgba(18, 18, 18, 0.85); + --accent: #c19e67; + --accent-dim: rgba(193, 158, 103, 0.15); + --accent-glow: rgba(193, 158, 103, 0.4); + --text-primary: #e6dbcc; + --text-secondary:#c19e67; + --text-muted: #8c8170; + --border: #4d4538; + --border-subtle: #363028; + --border-dropdown: #4d4538; + --shadow-dropdown: rgba(0, 0, 0, 0.7); + --positive: #2ecc71; + --warning: #f1c40f; + --danger: #e74c3c; + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 6px; +} + +[data-theme='azerothian-gold'] .sidebar { + border-right: 2px solid var(--border); + background: linear-gradient(180deg, #161616 0%, #0d0d0d 100%); +} + +[data-theme='azerothian-gold'] .player-bar { + border-top: 2px solid var(--border); + box-shadow: 0 -4px 10px rgba(0, 0, 0, 0.5); +} + +[data-theme='azerothian-gold'] .nav-link.active { + background: linear-gradient(90deg, rgba(193, 158, 103, 0.15) 0%, transparent 100%); + color: var(--ctp-mauve) !important; + text-shadow: 0 0 8px rgba(245, 212, 92, 0.4); +} + +[data-theme='azerothian-gold'] .nav-link.active::before { + background-color: var(--ctp-mauve); + box-shadow: 0 0 10px var(--ctp-mauve); +} + +[data-theme='azerothian-gold'] .player-track-name { + color: var(--ctp-mauve); + font-weight: 700; +} + +/* ─── Ascalon ─── */ +[data-theme='ascalon'] { + 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%23a68d5d%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: #0f0d0b; + --ctp-mantle: #151310; + --ctp-base: #1c1a17; + --ctp-surface0: #2a2722; + --ctp-surface1: #38342d; + --ctp-surface2: #4a453c; + --ctp-overlay0: #6b6355; + --ctp-overlay1: #8a7f6d; + --ctp-overlay2: #a69882; + --ctp-text: #f0ead6; + --ctp-subtext1: #d4cbb3; + --ctp-subtext0: #b8af98; + --ctp-mauve: #d4af37; + --ctp-lavender: #e5c158; + --ctp-pink: #b22222; + --ctp-flamingo: #cd853f; + --ctp-rosewater:#f0ead6; + --ctp-green: #6b8e23; + --ctp-teal: #4d5d53; + --ctp-sky: #87ceeb; + --ctp-blue: #4682b4; + --ctp-sapphire: #4682b4; + --ctp-yellow: #ffcc00; + --ctp-peach: #cd853f; + --ctp-maroon: #800000; + --ctp-red: #b22222; + + --bg-app: #1c1a17; + --bg-sidebar: #151310; + --bg-card: #2a2722; + --bg-hover: #38342d; + --bg-player: #0f0d0b; + --bg-glass: rgba(28, 26, 23, 0.85); + --accent: #d4af37; + --accent-dim: rgba(212, 175, 55, 0.15); + --accent-glow: rgba(212, 175, 55, 0.3); + --text-primary: #f0ead6; + --text-secondary:#a69882; + --text-muted: #6b6355; + --border: #4a453c; + --border-subtle: #2a2722; + --border-dropdown: #4a453c; + --shadow-dropdown: rgba(0, 0, 0, 0.7); + --positive: #6b8e23; + --warning: #cd853f; + --danger: #b22222; + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 6px; +} + +[data-theme='ascalon'] .sidebar { + border-right: 1px solid var(--border); + background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 100% 4px; +} + +[data-theme='ascalon'] .player-bar { + border-top: 2px solid var(--accent); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); +} + +[data-theme='ascalon'] .player-track-name { + color: var(--accent); + text-shadow: 0 0 8px var(--accent-glow); + font-family: 'Cinzel', serif, system-ui; +} + +[data-theme='ascalon'] .nav-link.active { + background: linear-gradient(90deg, var(--accent-dim) 0%, transparent 100%); + border-left: 3px solid var(--accent); + color: var(--accent); +} + +[data-theme='ascalon'] .player-btn-primary { + background: var(--accent); + color: var(--ctp-crust); + box-shadow: 0 0 15px var(--accent-glow); +} + +[data-theme='ascalon'] .player-btn-primary:hover { + transform: scale(1.05); + background: var(--ctp-lavender); +} + +/* ─── Grand Theft Audio ─── */ +[data-theme='grand-theft-audio'] { + 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%2357b05a%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: #0a0a0a; + --ctp-mantle: #0f0f0f; + --ctp-base: #141414; + --ctp-surface0: #1e1e1e; + --ctp-surface1: #282828; + --ctp-surface2: #323232; + --ctp-overlay0: #555555; + --ctp-overlay1: #888888; + --ctp-overlay2: #aaaaaa; + --ctp-text: #ffffff; + --ctp-subtext1: #cccccc; + --ctp-subtext0: #999999; + --ctp-mauve: #57b05a; + --ctp-lavender: #76d379; + --ctp-pink: #ec4899; + --ctp-flamingo: #f43f5e; + --ctp-rosewater:#fda4af; + --ctp-green: #57b05a; + --ctp-teal: #4d9e50; + --ctp-sky: #60a5fa; + --ctp-blue: #3b82f6; + --ctp-sapphire: #2563eb; + --ctp-yellow: #fbbf24; + --ctp-peach: #f59e0b; + --ctp-maroon: #b91c1c; + --ctp-red: #ef4444; + + --bg-app: #141414; + --bg-sidebar: #0f0f0f; + --bg-card: #1e1e1e; + --bg-hover: rgba(87, 176, 90, 0.1); + --bg-player: #0a0a0a; + --bg-glass: rgba(15, 15, 15, 0.85); + --accent: #57b05a; + --accent-dim: rgba(87, 176, 90, 0.15); + --accent-glow: rgba(87, 176, 90, 0.4); + --text-primary: #ffffff; + --text-secondary:#999999; + --text-muted: #666666; + --border: #333333; + --border-subtle: #1e1e1e; + --border-dropdown: #333333; + --shadow-dropdown: rgba(0, 0, 0, 0.8); + --positive: #57b05a; + --warning: #fbbf24; + --danger: #ef4444; + --radius-sm: 0px; + --radius-md: 2px; + --radius-lg: 4px; +} + +[data-theme='grand-theft-audio'] .sidebar { + border-right: 2px solid var(--accent); +} + +[data-theme='grand-theft-audio'] .player-bar { + border-top: 1px solid var(--border); +} + +[data-theme='grand-theft-audio'] .player-track-name { + text-transform: uppercase; + font-weight: 800; + letter-spacing: 0.5px; +} + +[data-theme='grand-theft-audio'] .nav-link.active { + background: linear-gradient(90deg, rgba(87, 176, 90, 0.2) 0%, transparent 100%); + color: var(--accent); +} + +[data-theme='grand-theft-audio'] .nav-link.active::before { + width: 4px; + box-shadow: 2px 0 10px var(--accent-glow); +} + +[data-theme='grand-theft-audio'] .btn-primary { + text-transform: uppercase; + font-weight: bold; +} + +/* ─── V-Tactical ─── */ +[data-theme='v-tactical'] { + 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%23708599%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: #090c0e; + --ctp-mantle: #0d1114; + --ctp-base: #161c22; + --ctp-surface0: #1f2830; + --ctp-surface1: #2a3640; + --ctp-surface2: #3a4a58; + --ctp-overlay0: #526371; + --ctp-overlay1: #708599; + --ctp-overlay2: #8da4b8; + --ctp-text: #e1e9ef; + --ctp-subtext1: #b0c1d1; + --ctp-subtext0: #8da4b8; + --ctp-mauve: #ff8a00; + --ctp-lavender: #ffaa45; + --ctp-pink: #bf616a; + --ctp-flamingo: #d08770; + --ctp-rosewater:#e1e9ef; + --ctp-green: #a3be8c; + --ctp-teal: #88c0d0; + --ctp-sky: #81a1c1; + --ctp-blue: #5e81ac; + --ctp-sapphire: #5e81ac; + --ctp-yellow: #ebcb8b; + --ctp-peach: #d08770; + --ctp-maroon: #bf616a; + --ctp-red: #bf616a; + + --bg-app: #161c22; + --bg-sidebar: #0d1114; + --bg-card: #1f2830; + --bg-hover: rgba(255, 138, 0, 0.1); + --bg-player: #090c0e; + --bg-glass: rgba(13, 17, 20, 0.85); + --accent: #ff8a00; + --accent-dim: rgba(255, 138, 0, 0.15); + --accent-glow: rgba(255, 138, 0, 0.4); + --text-primary: #e1e9ef; + --text-secondary:#b0c1d1; + --text-muted: #708599; + --border: #3a4a58; + --border-subtle: #1f2830; + --border-dropdown: #3a4a58; + --shadow-dropdown: rgba(0, 0, 0, 0.7); + --positive: #a3be8c; + --warning: #ebcb8b; + --danger: #bf616a; + --radius-sm: 1px; + --radius-md: 2px; + --radius-lg: 4px; +} + +[data-theme='v-tactical'] .sidebar { + border-right: 1px solid var(--border); +} + +[data-theme='v-tactical'] .player-bar { + border-top: 2px solid var(--accent); + background: linear-gradient(180deg, #0d1114 0%, #090c0e 100%); +} + +[data-theme='v-tactical'] .player-track-name { + text-transform: uppercase; + letter-spacing: 1px; + font-weight: 700; + color: var(--accent); + text-shadow: 0 0 10px var(--accent-glow); +} + +[data-theme='v-tactical'] .nav-link.active { + background: linear-gradient(90deg, rgba(255, 138, 0, 0.15) 0%, transparent 100%); + border-left: 3px solid var(--accent); +} + +[data-theme='v-tactical'] .btn-primary { + text-transform: uppercase; + font-weight: 800; + letter-spacing: 0.5px; +} + +/* ─── NightCity 2077 ─── */ +[data-theme='nightcity-2077'] { + 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%23FCEE0A%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: #000000; + --ctp-mantle: #0a0a0a; + --ctp-base: #050505; + --ctp-surface0: #151515; + --ctp-surface1: #202020; + --ctp-surface2: #2a2a2a; + --ctp-overlay0: #444444; + --ctp-overlay1: #666666; + --ctp-overlay2: #888888; + --ctp-text: #f0f0f0; + --ctp-subtext1: #cccccc; + --ctp-subtext0: #aaaaaa; + --ctp-mauve: #FCEE0A; + --ctp-lavender: #fff34d; + --ctp-pink: #ff00ff; + --ctp-flamingo: #FF003C; + --ctp-rosewater:#ffcccc; + --ctp-green: #00ff9f; + --ctp-teal: #00f0ff; + --ctp-sky: #00f0ff; + --ctp-blue: #3e59ff; + --ctp-sapphire: #2233aa; + --ctp-yellow: #FCEE0A; + --ctp-peach: #ffb800; + --ctp-maroon: #880000; + --ctp-red: #FF003C; + + --bg-app: #050505; + --bg-sidebar: #0a0a0a; + --bg-card: #151515; + --bg-hover: rgba(252, 238, 10, 0.1); + --bg-player: #000000; + --bg-glass: rgba(5, 5, 5, 0.85); + --accent: #FCEE0A; + --accent-dim: rgba(252, 238, 10, 0.15); + --accent-glow: rgba(252, 238, 10, 0.4); + --text-primary: #f0f0f0; + --text-secondary:#cccccc; + --text-muted: #666666; + --border: #FCEE0A; + --border-subtle: #2a2a2a; + --border-dropdown: #2a2a2a; + --shadow-dropdown: rgba(0, 0, 0, 0.9); + --positive: #00ff9f; + --warning: #ffb800; + --danger: #FF003C; + --radius-sm: 0px; + --radius-md: 2px; + --radius-lg: 4px; +} + +[data-theme='nightcity-2077'] .sidebar { + border-right: 1px solid var(--accent); + box-shadow: 2px 0 10px rgba(252, 238, 10, 0.1); +} + +[data-theme='nightcity-2077'] .player-bar { + border-top: 2px solid var(--accent); + background: linear-gradient(to bottom, #111, #000); +} + +[data-theme='nightcity-2077'] .player-track-name { + color: var(--accent); + text-transform: uppercase; + font-weight: 800; + letter-spacing: 1px; + text-shadow: 0 0 5px rgba(252, 238, 10, 0.5); +} + +[data-theme='nightcity-2077'] .nav-link.active { + background: linear-gradient(90deg, rgba(252, 238, 10, 0.2) 0%, transparent 100%); + color: var(--accent); + border-left: 3px solid var(--accent); +} + +[data-theme='nightcity-2077'] .nav-link.active::before { + display: none; +} + +[data-theme='nightcity-2077'] .btn-primary { + background-color: var(--accent); + color: #000; + font-weight: bold; + text-transform: uppercase; + clip-path: polygon(0% 0%, 100% 0%, 100% 70%, 90% 100%, 0% 100%); +} + +/* ─── Imperial Sith ─── */ +[data-theme='imperial-sith'] { + 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%23e60000%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: #050505; + --ctp-mantle: #0a0a0c; + --ctp-base: #0f0f11; + --ctp-surface0: #1a1a1d; + --ctp-surface1: #252529; + --ctp-surface2: #303035; + --ctp-overlay0: #4a4a4f; + --ctp-overlay1: #6e6e75; + --ctp-overlay2: #929299; + --ctp-text: #f0f0f0; + --ctp-subtext1: #c2c2c9; + --ctp-subtext0: #9e9ea5; + --ctp-mauve: #e60000; + --ctp-lavender: #ff3333; + --ctp-pink: #ff0000; + --ctp-flamingo: #ff3333; + --ctp-rosewater:#f0f0f0; + --ctp-green: #2ecc71; + --ctp-teal: #1abc9c; + --ctp-sky: #3498db; + --ctp-blue: #3498db; + --ctp-sapphire: #2980b9; + --ctp-yellow: #ffcc00; + --ctp-peach: #e67e22; + --ctp-maroon: #c0392b; + --ctp-red: #ff0000; + + --bg-app: #0f0f11; + --bg-sidebar: #0a0a0c; + --bg-card: #1a1a1d; + --bg-hover: #252529; + --bg-player: #050505; + --bg-glass: rgba(15, 15, 17, 0.85); + --accent: #e60000; + --accent-dim: rgba(230, 0, 0, 0.15); + --accent-glow: rgba(230, 0, 0, 0.45); + --text-primary: #f0f0f0; + --text-secondary:#c2c2c9; + --text-muted: #6e6e75; + --border: #252529; + --border-subtle: #1a1a1d; + --border-dropdown: #252529; + --shadow-dropdown: rgba(0, 0, 0, 0.85); + --positive: #2ecc71; + --warning: #ffcc00; + --danger: #ff0000; + --radius-sm: 0px; + --radius-md: 2px; + --radius-lg: 4px; +} + +[data-theme='imperial-sith'] .sidebar { + border-right: 1px solid #1a1a1d; + background: linear-gradient(180deg, #0a0a0c 0%, #050505 100%); +} + +[data-theme='imperial-sith'] .player-bar { + border-top: 1px solid #e6000033; + box-shadow: 0 -4px 20px rgba(230, 0, 0, 0.1); +} + +[data-theme='imperial-sith'] .player-track-name { + color: #f0f0f0; + text-shadow: 0 0 10px rgba(230, 0, 0, 0.6); + font-family: 'JetBrains Mono', monospace; +} + +[data-theme='imperial-sith'] .nav-link.active { + background: linear-gradient(90deg, rgba(230, 0, 0, 0.1) 0%, transparent 100%); +} + +[data-theme='imperial-sith'] .nav-link.active::before { + box-shadow: 0 0 10px #e60000; +} + +[data-theme='imperial-sith'] .btn-primary { + box-shadow: 0 0 15px rgba(230, 0, 0, 0.4); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* ─── Heisenberg ─── */ +[data-theme='heisenberg'] { + 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%233fe0ff%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: #0a0c0a; + --ctp-mantle: #121512; + --ctp-base: #1a1d1a; + --ctp-surface0: #242824; + --ctp-surface1: #2e332e; + --ctp-surface2: #383d38; + --ctp-overlay0: #5c635c; + --ctp-overlay1: #7f897f; + --ctp-overlay2: #a2b0a2; + --ctp-text: #e0e7e0; + --ctp-subtext1: #b0b8b0; + --ctp-subtext0: #8a928a; + --ctp-mauve: #3fe0ff; + --ctp-lavender: #87f0ff; + --ctp-pink: #ff79c6; + --ctp-flamingo: #ffb86c; + --ctp-rosewater:#f8f8f2; + --ctp-green: #47d08a; + --ctp-teal: #2bbbad; + --ctp-sky: #3fe0ff; + --ctp-blue: #3498db; + --ctp-sapphire: #2980b9; + --ctp-yellow: #f1c40f; + --ctp-peach: #e67e22; + --ctp-maroon: #c0392b; + --ctp-red: #ff4b2b; + + --bg-app: #1a1d1a; + --bg-sidebar: #121512; + --bg-card: #242824; + --bg-hover: rgba(63, 224, 255, 0.1); + --bg-player: #0a0c0a; + --bg-glass: rgba(26, 29, 26, 0.85); + --accent: #3fe0ff; + --accent-dim: rgba(63, 224, 255, 0.15); + --accent-glow: rgba(63, 224, 255, 0.4); + --text-primary: #e0e7e0; + --text-secondary:#b0b8b0; + --text-muted: #5c635c; + --border: #383d38; + --border-subtle: #242824; + --border-dropdown: #383d38; + --shadow-dropdown: rgba(0, 0, 0, 0.8); + --positive: #47d08a; + --warning: #f1c40f; + --danger: #ff4b2b; +} + +[data-theme='heisenberg'] .player-track-name { + color: var(--accent); + text-shadow: 0 0 10px var(--accent-glow); + font-family: 'JetBrains Mono', 'Fira Code', monospace; +} + +[data-theme='heisenberg'] .sidebar { + border-right: 1px solid var(--accent-dim); +} + +[data-theme='heisenberg'] .nav-link.active { + background: linear-gradient(90deg, var(--accent-dim) 0%, transparent 100%); +} + +[data-theme='heisenberg'] .player-bar { + border-top: 1px solid var(--accent-dim); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5); +} + +/* ─── A Theme of Ice and Fire ─── */ +[data-theme='ice-and-fire'] { + 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%2370a1ff%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: #05070a; + --ctp-mantle: #0d1117; + --ctp-base: #121820; + --ctp-surface0: #1c2533; + --ctp-surface1: #2a374a; + --ctp-surface2: #384961; + --ctp-overlay0: #5c6a7e; + --ctp-overlay1: #7d8da3; + --ctp-overlay2: #a0b1c7; + --ctp-text: #f0f4f8; + --ctp-subtext1: #cbd5e0; + --ctp-subtext0: #a0aec0; + --ctp-mauve: #70a1ff; + --ctp-lavender: #a4b0be; + --ctp-pink: #ff4757; + --ctp-flamingo: #ff6b81; + --ctp-rosewater:#f0f4f8; + --ctp-green: #2ed573; + --ctp-teal: #1e90ff; + --ctp-sky: #70a1ff; + --ctp-blue: #1e90ff; + --ctp-sapphire: #1e90ff; + --ctp-yellow: #ffa502; + --ctp-peach: #ffa502; + --ctp-maroon: #c0392b; + --ctp-red: #ff4757; + + --bg-app: #121820; + --bg-sidebar: #0d1117; + --bg-card: #1c2533; + --bg-hover: rgba(112, 161, 255, 0.1); + --bg-player: #05070a; + --bg-glass: rgba(13, 17, 23, 0.85); + --accent: #70a1ff; + --accent-dim: rgba(112, 161, 255, 0.15); + --accent-glow: rgba(112, 161, 255, 0.25); + --volume-accent: #ffa502; + --text-primary: #f0f4f8; + --text-secondary:#cbd5e0; + --text-muted: #5c6a7e; + --border: #2a374a; + --border-subtle: #1c2533; + --border-dropdown: #2a374a; + --shadow-dropdown: rgba(0, 0, 0, 0.8); + --positive: #2ed573; + --warning: #ffa502; + --danger: #ff4757; + --radius-sm: 1px; + --radius-md: 2px; + --radius-lg: 4px; +} + +[data-theme='ice-and-fire'] .app-shell { + background: radial-gradient(circle at top right, #1a2a3a, #121820); +} + +[data-theme='ice-and-fire'] .sidebar { + border-right: 1px solid #1c2533; + background: linear-gradient(to bottom, #0d1117, #05070a); +} + +[data-theme='ice-and-fire'] .player-bar { + border-top: 2px solid #ffa502; + box-shadow: 0 -4px 30px rgba(255, 165, 2, 0.1); + background: #05070a; +} + +[data-theme='ice-and-fire'] .nav-link.active { + background: linear-gradient(90deg, rgba(112, 161, 255, 0.15) 0%, transparent 100%); + color: #70a1ff; + font-weight: 600; + text-shadow: 0 0 10px rgba(112, 161, 255, 0.4); +} + +[data-theme='ice-and-fire'] .slider-track-fill { + background: linear-gradient(90deg, #70a1ff, #ffa502) !important; +} + +[data-theme='ice-and-fire'] .player-track-name { + color: #f0f4f8; + text-shadow: 0 0 12px rgba(112, 161, 255, 0.6); +} + +[data-theme='ice-and-fire'] .btn { + border: 1px solid var(--border); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.1em; +} + +/* ─── D'oh-matic ─── */ +[data-theme='doh-matic'] { 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"); + --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%23444444%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; + --ctp-crust: #FFD90F; + --ctp-mantle: #BAE1FF; + --ctp-base: #FFFDF0; + --ctp-surface0: #F0F8FF; + --ctp-surface1: #E0EFFF; + --ctp-surface2: #D0E5FF; + --ctp-overlay0: #999999; + --ctp-overlay1: #777777; + --ctp-overlay2: #555555; + --ctp-text: #222222; + --ctp-subtext1: #444444; + --ctp-subtext0: #666666; + --ctp-mauve: #1F75FE; + --ctp-lavender: #4D91FF; + --ctp-pink: #F14E96; + --ctp-flamingo: #F14E96; + --ctp-rosewater:#FFD90F; + --ctp-green: #4AC948; + --ctp-teal: #00BFAF; + --ctp-sky: #4D91FF; + --ctp-blue: #1F75FE; + --ctp-sapphire: #1F75FE; + --ctp-yellow: #FFD90F; + --ctp-peach: #F26522; + --ctp-maroon: #CC0000; + --ctp-red: #ED1C24; - --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; + --bg-app: #FFFDF0; + --bg-sidebar: #BAE1FF; + --bg-card: #FFFFFF; + --bg-hover: rgba(31, 117, 254, 0.1); + --bg-player: #FFD90F; + --bg-glass: rgba(255, 253, 240, 0.8); + --accent: #1F75FE; + --accent-dim: rgba(31, 117, 254, 0.15); + --accent-glow: rgba(31, 117, 254, 0.3); + --text-primary: #222222; + --text-secondary:#444444; + --text-muted: #777777; + --border: #222222; + --border-subtle: rgba(0, 0, 0, 0.1); + --border-dropdown: #cccccc; + --shadow-dropdown: rgba(0, 0, 0, 0.15); + --positive: #4AC948; + --warning: #F26522; + --danger: #ED1C24; + --radius-md: 12px; +} + +[data-theme='doh-matic'] .sidebar { + border-right: 2px solid #222222; +} + +[data-theme='doh-matic'] .player-bar { + border-top: 3px solid #222222; +} + +[data-theme='doh-matic'] .player-track-name, +[data-theme='doh-matic'] .player-track-artist, +[data-theme='doh-matic'] .player-time { + color: #222222 !important; +} + +[data-theme='doh-matic'] .player-btn { + color: #222222; +} + +[data-theme='doh-matic'] .btn-primary:hover { + background-color: var(--ctp-pink); + border-color: #222222; +} + +/* ─── Order of the Phoenix ─── */ +[data-theme='order-of-the-phoenix'] { + 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%23ff6b00%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: #0a0a0a; + --ctp-mantle: #121212; + --ctp-base: #181818; + --ctp-surface0: #222222; + --ctp-surface1: #2a2a2a; + --ctp-surface2: #333333; + --ctp-overlay0: #555555; + --ctp-overlay1: #888888; + --ctp-overlay2: #aaaaaa; + --ctp-text: #f5f5f5; + --ctp-subtext1: #d1d1d1; + --ctp-subtext0: #b0b0b0; + --ctp-mauve: #e63900; + --ctp-lavender: #ff6b00; + --ctp-pink: #ff0000; + --ctp-flamingo: #ff4500; + --ctp-rosewater:#f5f5f5; + --ctp-green: #228b22; + --ctp-teal: #008080; + --ctp-sky: #87ceeb; + --ctp-blue: #1e90ff; + --ctp-sapphire: #1e90ff; + --ctp-yellow: #ffd700; + --ctp-peach: #ff6b00; + --ctp-maroon: #8b0000; + --ctp-red: #ff0000; + + --bg-app: #181818; + --bg-sidebar: #121212; + --bg-card: #222222; + --bg-hover: rgba(230, 57, 0, 0.1); + --bg-player: #0a0a0a; + --bg-glass: rgba(24, 24, 24, 0.85); + --accent: #e63900; + --accent-dim: rgba(230, 57, 0, 0.15); + --accent-glow: rgba(230, 57, 0, 0.4); + --text-primary: #f5f5f5; + --text-secondary:#b0b0b0; + --text-muted: #666666; + --border: #333333; + --border-subtle: #222222; + --border-dropdown: #333333; + --shadow-dropdown: rgba(0, 0, 0, 0.8); + --positive: #228b22; + --warning: #ffd700; + --danger: #ff0000; + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 16px; +} + +[data-theme='order-of-the-phoenix'] .sidebar { + border-right: 1px solid rgba(230, 57, 0, 0.2); + background-image: linear-gradient(to bottom, rgba(230, 57, 0, 0.05), transparent); +} + +[data-theme='order-of-the-phoenix'] .player-bar { + border-top: 1px solid rgba(230, 57, 0, 0.3); + box-shadow: 0 -4px 25px rgba(230, 57, 0, 0.15); +} + +[data-theme='order-of-the-phoenix'] .player-track-name { + color: var(--ctp-lavender); + text-shadow: 0 0 12px rgba(255, 107, 0, 0.5); + font-weight: 600; +} + +[data-theme='order-of-the-phoenix'] .nav-link.active { + background: linear-gradient(90deg, rgba(230, 57, 0, 0.15) 0%, transparent 100%); + color: var(--ctp-lavender); +} + +[data-theme='order-of-the-phoenix'] .nav-link.active::before { + box-shadow: 0 0 15px var(--accent); + background-color: var(--accent); +} + +[data-theme='order-of-the-phoenix'] .btn-primary { + background: linear-gradient(135deg, #e63900, #ff6b00); + border: none; + box-shadow: 0 0 15px rgba(230, 57, 0, 0.4); +} + +/* ─── W98 ─── */ +[data-theme='w98'] { + 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%23000000%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: #808080; + --ctp-mantle: #a0a0a0; + --ctp-base: #c0c0c0; + --ctp-surface0: #dfdfdf; + --ctp-surface1: #ffffff; + --ctp-surface2: #000080; + --ctp-overlay0: #808080; + --ctp-overlay1: #404040; + --ctp-overlay2: #202020; + --ctp-text: #000000; + --ctp-subtext1: #222222; + --ctp-subtext0: #444444; + --ctp-mauve: #000080; + --ctp-lavender: #0000af; + --ctp-pink: #ff0000; + --ctp-flamingo: #ff0000; + --ctp-rosewater:#c0c0c0; + --ctp-green: #008000; + --ctp-teal: #008080; + --ctp-sky: #0000ff; + --ctp-blue: #0000ff; + --ctp-sapphire: #000080; + --ctp-yellow: #ffff00; + --ctp-peach: #ff8000; + --ctp-maroon: #800000; + --ctp-red: #ff0000; + + --bg-app: #008080; + --bg-sidebar: #c0c0c0; + --bg-card: #c0c0c0; + --bg-hover: #000080; + --bg-player: #c0c0c0; + --bg-glass: rgba(192, 192, 192, 0.9); + --accent: #000080; + --accent-dim: rgba(0, 0, 128, 0.1); + --accent-glow: rgba(0, 0, 128, 0.2); + --text-primary: #000000; + --text-secondary:#000000; + --text-muted: #404040; + --border: #808080; + --border-subtle: #a0a0a0; + --border-dropdown: #808080; + --shadow-dropdown: rgba(0, 0, 0, 0.3); + --positive: #008000; + --warning: #808000; + --danger: #ff0000; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; +} + +[data-theme='w98'] .app-shell { + background-color: var(--bg-app); +} + +[data-theme='w98'] .card, +[data-theme='w98'] .sidebar, +[data-theme='w98'] .player-bar, +[data-theme='w98'] .btn-surface { + border: 1px solid #ffffff !important; + border-right-color: #808080 !important; + border-bottom-color: #808080 !important; + box-shadow: 1px 1px 0 0 #000, inset 1px 1px 0 0 #dfdfdf !important; +} + +[data-theme='w98'] .nav-link.active { + background-color: #000080 !important; + color: #ffffff !important; +} + +[data-theme='w98'] .nav-link:hover:not(.active) { + outline: 1px dotted #000; + background-color: transparent !important; +} + +[data-theme='w98'] .player-track-name, +[data-theme='w98'] .player-track-artist, +[data-theme='w98'] .player-time { + color: #000000 !important; +} + +[data-theme='w98'] ::-webkit-scrollbar { + width: 16px; + background: #dfdfdf; +} + +[data-theme='w98'] ::-webkit-scrollbar-thumb { + background: #c0c0c0; + border: 1px solid #ffffff; + border-right-color: #808080; + border-bottom-color: #808080; + box-shadow: 1px 1px 0 0 #000; } /* ─── Global Base Settings ─── */ @@ -1589,6 +2466,7 @@ body { font-size: 14px; line-height: 1.5; color: var(--text-primary); + user-select: none; background: var(--bg-app); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -3373,6 +4251,8 @@ input[type="range"]:hover::-webkit-slider-thumb { [data-theme='middle-earth'] { color-scheme: light; + --color-star-active: #4a3728; + --color-star-inactive: #6e5a42; --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%234a3728%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"); @@ -3450,12 +4330,21 @@ input[type="range"]:hover::-webkit-slider-thumb { } [data-theme='middle-earth'] .player-btn { color: #dec48d; } [data-theme='middle-earth'] .player-btn:hover { color: #d4af37; } +[data-theme='middle-earth'] .player-btn-primary { color: #2a1d15; } +[data-theme='middle-earth'] .player-btn-primary:hover { color: #2a1d15; } -/* Queue läuft auf dunklem Player-Hintergrund — helle Textfarben nötig */ +/* Queue + Lyrics laufen auf dunklem Sidebar-Hintergrund — helle Textfarben nötig */ [data-theme='middle-earth'] .queue-current-info h3 { color: #f4e4bc; } [data-theme='middle-earth'] .queue-current-sub { color: #d4af37; } [data-theme='middle-earth'] .queue-item-title { color: #f4e4bc; } [data-theme='middle-earth'] .queue-item-artist { color: #cbb07c; } +[data-theme='middle-earth'] .queue-tab-btn { color: #cbb07c; } +[data-theme='middle-earth'] .queue-tab-btn:hover { color: #f4e4bc; background: rgba(212, 175, 55, 0.1); } +[data-theme='middle-earth'] .queue-tab-btn.active { color: #d4af37; } +[data-theme='middle-earth'] .lyrics-line { color: #8c7356; } +[data-theme='middle-earth'] .lyrics-line.active { color: #f4e4bc; } +[data-theme='middle-earth'] .lyrics-plain-line { color: #cbb07c; } +[data-theme='middle-earth'] .lyrics-status { color: #8c7356; } /* Dezentes Pergament-Noise über der App */ [data-theme='middle-earth'] .app-shell::after {