diff --git a/CHANGELOG.md b/CHANGELOG.md index fa92c0c0..64aac14e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ 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.6.0] - 2026-03-19 + +### Added + +#### Replay Gain +- **Replay Gain support** in the Rust audio engine. Gain and peak values from the Subsonic API are applied per-track at playback time, keeping loudness consistent across your library. +- Two modes selectable in Settings → Playback: **Track** (default) and **Album** gain. +- Peak limiting applied to prevent clipping: effective gain is capped at `1 / peak`. +- Volume slider preserves the gain ratio — `audio_set_volume` multiplies `base_volume × replay_gain_linear`. + +#### Crossfade +- **Crossfade between tracks** (0.5 – 12 s, configurable in Settings → Playback). +- Old sink is volume-ramped to zero in 30 steps while the new track starts playing; old sink stored in `fading_out_sink` so a subsequent skip cancels the fade-out immediately. +- `audio_set_crossfade` Tauri command; synced to Rust on startup and on toggle. + +#### Gapless Preloading *(Experimental — Alpha)* +- **Gapless playback**: when ≤ 30 s remain in the current track, the next track's audio is preloaded via `audio_preload` in the background. +- `audio_play` checks the preload cache first — if there is a URL match the download is skipped entirely, eliminating the gap between tracks. +- The old Sink is kept alive during the new track's download and decode phase; the Sink swap happens atomically after decoding is complete, fixing a subtle **start-of-track audio cut** that occurred regardless of gapless state. +- ⚠️ **This feature is experimental and still in active development.** It may not work correctly in all scenarios. Enable it in Settings → Playback at your own discretion. + +#### Settings — Tab Navigation +- Settings reorganised into **5 horizontal tabs**: Playback, Library, Appearance, Server, About. +- Each tab groups related settings with a matching icon. + +#### Artist Pages — "Also Featured On" +- Artist detail pages now show an **"Also Featured On"** section listing albums where the artist appears as a guest or featured performer (but is not the primary album artist). +- Implemented via `search3` filtered by `song.artistId`, excluding the artist's own albums. + +#### Download Folder Modal +- When no download folder is configured and the user initiates a download (album or track), a **folder picker modal** now appears asking where to save. +- Includes a "Remember this folder" checkbox that writes the choice to Settings. +- Clear button added in Settings → Server to reset the saved download folder. + +#### Changelog in Settings +- The full **Changelog** is now readable inside the app under Settings → About. +- Rendered as collapsible version entries; the current version is expanded by default. +- Inline Markdown (`**bold**`, `*italic*`, `` `code` ``) is rendered natively. + +#### EQ as Player Bar Popup +- The Equalizer is now accessible directly from the **player bar** via a small EQ button, opening as a centred popup overlay — no need to navigate to Settings. + +### Fixed + +- **Bundle identifier warning**: changed `identifier` from `dev.psysonic.app` to `dev.psysonic.player` to avoid the macOS `.app` extension conflict warned by Tauri. +- **Version mismatch in releases**: `tauri.conf.json` version was out of sync with `package.json` and `Cargo.toml`, causing GitHub Actions to build release artefacts with the wrong version number. All four version sources (`package.json`, `Cargo.toml`, `tauri.conf.json`, `packages/aur/PKGBUILD`) are now kept in sync. + +### Known Issues + +- **FLAC seeking**: jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. + +--- + ## [1.5.0] - 2026-03-18 ### Added diff --git a/README.md b/README.md index 70cfe913..c3b3f244 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ Designed specifically for users hosting their own music via Navidrome or other S ## ● Known Limitations - **Linux (drag & drop cursor feedback)**: Due to a WebKitGTK limitation, the drag cursor does not reflect the drop operation type — it may appear as a "forbidden" symbol or show no indicator at all, depending on the desktop environment. Drag and drop itself works correctly. +- **FLAC seeking**: Jumping to a position in a FLAC file via the waveform seekbar currently does not work. Seeking in MP3, OGG, and other formats is unaffected. This is a known issue and will be investigated. ## 📥 Installation diff --git a/package.json b/package.json index af45f112..eb025403 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psysonic", - "version": "1.5.0", + "version": "1.6.0", "private": true, "scripts": { "dev": "vite", diff --git a/packages/aur/PKGBUILD b/packages/aur/PKGBUILD index f396dc62..9cc1b0ec 100644 --- a/packages/aur/PKGBUILD +++ b/packages/aur/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: stelle pkgname=psysonic -pkgver=1.5.0 +pkgver=1.6.0 pkgrel=1 pkgdesc="Desktop music player for Subsonic API-compatible servers (Navidrome, Gonic, etc.)" arch=('x86_64') diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a145a31..9831d211 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "psysonic" -version = "1.5.0" +version = "1.6.0" description = "Psysonic Desktop Music Player" authors = [] license = "" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 50095af6..e071bdc2 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -137,6 +137,12 @@ impl> Source for EqSource { // ─── Engine state (registered as Tauri managed state) ──────────────────────── +pub(crate) struct PreloadedTrack { + url: String, + data: Vec, + duration_hint: f64, +} + pub struct AudioEngine { pub stream_handle: Arc, pub current: Arc>, @@ -147,6 +153,10 @@ pub struct AudioEngine { pub http_client: reqwest::Client, pub eq_gains: Arc<[AtomicU32; 10]>, pub eq_enabled: Arc, + pub preloaded: Arc>>, + pub crossfade_enabled: Arc, + pub crossfade_secs: Arc, // f32 stored as bits + pub fading_out_sink: Arc>>, } pub struct AudioCurrent { @@ -159,6 +169,8 @@ pub struct AudioCurrent { pub play_started: Option, /// Set when paused; holds the position at pause time. pub paused_at: Option, + pub replay_gain_linear: f32, + pub base_volume: f32, } impl AudioCurrent { @@ -207,6 +219,8 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { seek_offset: 0.0, play_started: None, paused_at: None, + replay_gain_linear: 1.0, + base_volume: 0.8, })), generation: Arc::new(AtomicU64::new(0)), http_client: reqwest::Client::builder() @@ -215,6 +229,10 @@ pub fn create_engine() -> (AudioEngine, std::thread::JoinHandle<()>) { .unwrap_or_default(), eq_gains: Arc::new(std::array::from_fn(|_| AtomicU32::new(0f32.to_bits()))), eq_enabled: Arc::new(AtomicBool::new(false)), + preloaded: Arc::new(Mutex::new(None)), + crossfade_enabled: Arc::new(AtomicBool::new(false)), + crossfade_secs: Arc::new(AtomicU32::new(3.0f32.to_bits())), + fading_out_sink: Arc::new(Mutex::new(None)), }; (engine, thread) @@ -238,43 +256,61 @@ pub async fn audio_play( url: String, volume: f32, duration_hint: f64, + replay_gain_db: Option, + replay_gain_peak: Option, app: AppHandle, state: State<'_, AudioEngine>, ) -> Result<(), String> { // Claim this generation — any in-flight progress task with the old gen will exit. let gen = state.generation.fetch_add(1, Ordering::SeqCst) + 1; - // Stop existing playback immediately. + // Stop any previous fading sink unconditionally. { - let mut cur = state.current.lock().unwrap(); - if let Some(sink) = cur.sink.take() { - sink.stop(); + let mut fo = state.fading_out_sink.lock().unwrap(); + if let Some(old) = fo.take() { + old.stop(); } - cur.seek_offset = 0.0; - cur.play_started = None; - cur.paused_at = None; - cur.duration_secs = duration_hint; } - // ── Download ────────────────────────────────────────────────────────────── - let response = state - .http_client - .get(&url) - .send() - .await - .map_err(|e| e.to_string())?; + // NOTE: We intentionally do NOT stop the current Sink here. + // The old Sink keeps playing while we download + decode the new track. + // We swap Sinks atomically only after the new data is ready, so the + // old track plays to completion (or as close to it as possible). - if !response.status().is_success() { - let status = response.status().as_u16(); - if state.generation.load(Ordering::SeqCst) != gen { - return Ok(()); + // ── Check preload cache or download ────────────────────────────────────── + let data: Vec = { + let cached = { + let mut preloaded = state.preloaded.lock().unwrap(); + if let Some(p) = preloaded.as_ref().filter(|p| p.url == url) { + let d = p.data.clone(); + let _ = p.duration_hint; // used for type check + *preloaded = None; + Some(d) + } else { + None + } + }; + if let Some(cached_data) = cached { + cached_data + } else { + let response = state + .http_client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + let status = response.status().as_u16(); + if state.generation.load(Ordering::SeqCst) != gen { + return Ok(()); + } + let msg = format!("HTTP {status}"); + app.emit("audio:error", &msg).ok(); + return Err(msg); + } + response.bytes().await.map_err(|e| e.to_string())?.into() } - let msg = format!("HTTP {status}"); - app.emit("audio:error", &msg).ok(); - return Err(msg); - } - - let bytes = response.bytes().await.map_err(|e| e.to_string())?; + }; // Bail if superseded while downloading. if state.generation.load(Ordering::SeqCst) != gen { @@ -282,7 +318,6 @@ pub async fn audio_play( } // ── Decode ──────────────────────────────────────────────────────────────── - let data: Vec = bytes.into(); // Trust the Subsonic API duration_hint as the primary source. // Decoder::total_duration() is unreliable for VBR MP3 (symphonia may @@ -311,9 +346,21 @@ pub async fn audio_play( return Ok(()); } - // ── Create sink and start playback ──────────────────────────────────────── + // ── Compute replay gain ─────────────────────────────────────────────────── + let gain_linear: f32 = replay_gain_db + .map(|db| 10f32.powf(db / 20.0)) + .unwrap_or(1.0); + let peak = replay_gain_peak.unwrap_or(1.0).max(0.001); + // Limit gain so peak sample doesn't exceed 1.0 (prevent clipping). + let gain_linear = gain_linear.min(1.0 / peak); + let effective_volume = (volume.clamp(0.0, 1.0) * gain_linear).clamp(0.0, 1.0); + + // ── Create new sink ─────────────────────────────────────────────────────── + let crossfade_enabled = state.crossfade_enabled.load(Ordering::Relaxed); + let crossfade_secs_val = f32::from_bits(state.crossfade_secs.load(Ordering::Relaxed)).clamp(0.5, 12.0); + let sink = Sink::try_new(&*state.stream_handle).map_err(|e| e.to_string())?; - sink.set_volume(volume.clamp(0.0, 1.0)); + sink.set_volume(effective_volume); let eq_source = EqSource::new( decoder.convert_samples::(), state.eq_gains.clone(), @@ -321,13 +368,49 @@ pub async fn audio_play( ); sink.append(eq_source); - { + // Atomically swap sinks: take old one, install new one. + // Capture old volume so the crossfade fade-out starts at the right level. + let (old_sink, old_vol) = { let mut cur = state.current.lock().unwrap(); + let old_vol = (cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0); + let old = cur.sink.take(); cur.sink = Some(sink); cur.duration_secs = duration_secs; cur.seek_offset = 0.0; cur.play_started = Some(Instant::now()); cur.paused_at = None; + cur.replay_gain_linear = gain_linear; + cur.base_volume = volume.clamp(0.0, 1.0); + (old, old_vol) + }; + + // Handle old sink: crossfade fade-out or immediate stop. + // The new track is already playing; the old sink runs in parallel during a crossfade. + if crossfade_enabled { + if let Some(old) = old_sink { + // Park the old sink in fading_out_sink so a subsequent audio_play can cancel it. + *state.fading_out_sink.lock().unwrap() = Some(old); + let fo_arc = state.fading_out_sink.clone(); + tokio::spawn(async move { + let steps: u32 = 30; + let step_ms = ((crossfade_secs_val * 1000.0) / steps as f32) as u64; + for i in (0..=steps).rev() { + { + let fo = fo_arc.lock().unwrap(); + match fo.as_ref() { + Some(s) => s.set_volume(old_vol * (i as f32 / steps as f32)), + None => return, // cancelled by a subsequent audio_play + } + } + tokio::time::sleep(Duration::from_millis(step_ms)).await; + } + if let Some(s) = fo_arc.lock().unwrap().take() { + s.stop(); + } + }); + } + } else if let Some(old) = old_sink { + old.stop(); } app.emit("audio:playing", duration_secs).ok(); @@ -340,13 +423,14 @@ pub async fn audio_play( // Instead we use the wall-clock position (seek_offset + elapsed). // `AudioCurrent::position()` is clamped to `duration_secs`, so once it // reaches the end it stays there. We fire `audio:ended` after two - // consecutive ticks where position >= duration - 1.0 s, which: - // • avoids false positives from seeking very close to the end - // • fires roughly 0.5–1 s before the last sample, giving the frontend - // enough time to queue the next download. + // consecutive ticks where position >= duration - threshold, where threshold + // is extended to crossfade_secs when crossfade is enabled so the frontend + // gets time to start the next track during the fade. let gen_counter = state.generation.clone(); let current_arc = state.current.clone(); let app_clone = app.clone(); + let crossfade_enabled_arc = state.crossfade_enabled.clone(); + let crossfade_secs_arc = state.crossfade_secs.clone(); tokio::spawn(async move { let mut near_end_ticks: u32 = 0; @@ -375,8 +459,11 @@ pub async fn audio_play( continue; } + let cf_enabled = crossfade_enabled_arc.load(Ordering::Relaxed); + let cf_secs = f32::from_bits(crossfade_secs_arc.load(Ordering::Relaxed)).clamp(0.5, 12.0) as f64; + let end_threshold = if cf_enabled { cf_secs.max(1.0) } else { 1.0 }; - if dur > 1.0 && pos >= dur - 1.0 { + if dur > end_threshold && pos >= dur - end_threshold { near_end_ticks += 1; if near_end_ticks >= 2 { gen_counter.fetch_add(1, Ordering::SeqCst); @@ -450,9 +537,10 @@ pub fn audio_seek(seconds: f64, state: State<'_, AudioEngine>) -> Result<(), Str #[tauri::command] pub fn audio_set_volume(volume: f32, state: State<'_, AudioEngine>) { - let cur = state.current.lock().unwrap(); + let mut cur = state.current.lock().unwrap(); + cur.base_volume = volume.clamp(0.0, 1.0); if let Some(sink) = &cur.sink { - sink.set_volume(volume.clamp(0.0, 1.0)); + sink.set_volume((cur.base_volume * cur.replay_gain_linear).clamp(0.0, 1.0)); } } @@ -463,3 +551,36 @@ pub fn audio_set_eq(gains: [f32; 10], enabled: bool, state: State<'_, AudioEngin state.eq_gains[i].store(gain.clamp(-12.0, 12.0).to_bits(), Ordering::Relaxed); } } + +#[tauri::command] +pub async fn audio_preload( + url: String, + duration_hint: f64, + state: State<'_, AudioEngine>, +) -> Result<(), String> { + // Don't re-download if already preloaded for this URL. + { + let preloaded = state.preloaded.lock().unwrap(); + if preloaded.as_ref().map(|p| p.url == url).unwrap_or(false) { + return Ok(()); + } + } + let response = state + .http_client + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Ok(()); // silently fail preload + } + let data: Vec = response.bytes().await.map_err(|e| e.to_string())?.into(); + *state.preloaded.lock().unwrap() = Some(PreloadedTrack { url, data, duration_hint }); + Ok(()) +} + +#[tauri::command] +pub fn audio_set_crossfade(enabled: bool, secs: f32, state: State<'_, AudioEngine>) { + state.crossfade_enabled.store(enabled, Ordering::Relaxed); + state.crossfade_secs.store(secs.clamp(0.5, 12.0).to_bits(), Ordering::Relaxed); +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7ebf4f4c..4768b97b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -126,6 +126,8 @@ pub fn run() { audio::audio_seek, audio::audio_set_volume, audio::audio_set_eq, + audio::audio_preload, + audio::audio_set_crossfade, ]) .run(tauri::generate_context!()) .expect("error while running Psysonic"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 21b3f47e..ea736a7b 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Psysonic", - "version": "1.4.5", - "identifier": "dev.psysonic.app", + "version": "1.6.0", + "identifier": "dev.psysonic.player", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/src/App.tsx b/src/App.tsx index c2599def..4fe05f53 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import SearchResults from './pages/SearchResults'; import NowPlayingPage from './pages/NowPlaying'; import FullscreenPlayer from './components/FullscreenPlayer'; import ContextMenu from './components/ContextMenu'; +import DownloadFolderModal from './components/DownloadFolderModal'; import ConnectionIndicator from './components/ConnectionIndicator'; import OfflineOverlay from './components/OfflineOverlay'; import { useConnectionStatus } from './hooks/useConnectionStatus'; @@ -189,6 +190,7 @@ function AppShell() { )} + ); } diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index a18e4570..df537bce 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -76,6 +76,13 @@ export interface SubsonicSong { starred?: string; genre?: string; path?: string; + albumArtist?: string; + replayGain?: { + trackGain?: number; + albumGain?: number; + trackPeak?: number; + albumPeak?: number; + }; } export interface SubsonicPlaylist { diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 888df8d4..d3833a7b 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -4,6 +4,7 @@ import { usePlayerStore, Track } from '../store/playerStore'; import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum } from '../api/subsonic'; import { useNavigate } from 'react-router-dom'; import { useAuthStore } from '../store/authStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; import { open } from '@tauri-apps/plugin-shell'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; @@ -21,6 +22,7 @@ export default function ContextMenu() { const { t } = useTranslation(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack } = usePlayerStore(); const auth = useAuthStore(); + const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const navigate = useNavigate(); const menuRef = useRef(null); @@ -74,25 +76,16 @@ export default function ContextMenu() { const downloadAlbum = async (albumName: string, albumId: string) => { try { + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; + const url = buildDownloadUrl(albumId); const response = await fetch(url); if (!response.ok) throw new Error(`HTTP ${response.status}`); const blob = await response.blob(); - - if (auth.downloadFolder) { - const buffer = await blob.arrayBuffer(); - const path = await join(auth.downloadFolder, `${sanitizeFilename(albumName)}.zip`); - await writeFile(path, new Uint8Array(buffer)); - } else { - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = `${sanitizeFilename(albumName)}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(blobUrl), 2000); - } + const buffer = await blob.arrayBuffer(); + const path = await join(folder, `${sanitizeFilename(albumName)}.zip`); + await writeFile(path, new Uint8Array(buffer)); } catch (e) { console.error('Download failed:', e); } diff --git a/src/components/DownloadFolderModal.tsx b/src/components/DownloadFolderModal.tsx new file mode 100644 index 00000000..eb535572 --- /dev/null +++ b/src/components/DownloadFolderModal.tsx @@ -0,0 +1,56 @@ +import { FolderOpen } from 'lucide-react'; +import { open as openDialog } from '@tauri-apps/plugin-dialog'; +import { useTranslation } from 'react-i18next'; +import { useDownloadModalStore } from '../store/downloadModalStore'; +import { useAuthStore } from '../store/authStore'; + +export default function DownloadFolderModal() { + const { isOpen, folder, remember, setFolder, setRemember, confirm, cancel } = useDownloadModalStore(); + const setDownloadFolder = useAuthStore(s => s.setDownloadFolder); + const { t } = useTranslation(); + + const handleBrowse = async () => { + const selected = await openDialog({ directory: true, multiple: false, title: t('common.chooseDownloadFolder') }); + if (selected && typeof selected === 'string') setFolder(selected); + }; + + if (!isOpen) return null; + + return ( + <> +
+
+
+ {t('common.chooseDownloadFolder')} +
+ +
+
+ + {folder || t('common.noFolderSelected')} + + +
+ + +
+ +
+ + +
+
+ + ); +} diff --git a/src/components/Equalizer.tsx b/src/components/Equalizer.tsx index 09ad24e0..d1b30bd2 100644 --- a/src/components/Equalizer.tsx +++ b/src/components/Equalizer.tsx @@ -2,6 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { Save, Trash2, RotateCcw } from 'lucide-react'; import { useEqStore, EQ_BANDS, BUILTIN_PRESETS } from '../store/eqStore'; +import { useThemeStore } from '../store/themeStore'; // ─── Frequency response canvas ──────────────────────────────────────────────── @@ -11,31 +12,31 @@ const EQ_Q = 1.41; function biquadPeakResponse(freq: number, centerHz: number, gainDb: number, sampleRate: number): number { if (Math.abs(gainDb) < 0.01) return 0; const w0 = (2 * Math.PI * centerHz) / sampleRate; - const A = Math.pow(10, gainDb / 40); + const A = Math.pow(10, gainDb / 40); const alpha = Math.sin(w0) / (2 * EQ_Q); - const b0 = 1 + alpha * A; + const b0 = 1 + alpha * A; const b1 = -2 * Math.cos(w0); - const b2 = 1 - alpha * A; - const a0 = 1 + alpha / A; + const b2 = 1 - alpha * A; + const a0 = 1 + alpha / A; const a1 = -2 * Math.cos(w0); - const a2 = 1 - alpha / A; - const w = (2 * Math.PI * freq) / sampleRate; + const a2 = 1 - alpha / A; + const w = (2 * Math.PI * freq) / sampleRate; const cosW = Math.cos(w), sinW = Math.sin(w); const cos2W = Math.cos(2 * w), sin2W = Math.sin(2 * w); const numRe = b0 + b1 * cosW + b2 * cos2W; - const numIm = - b1 * sinW - b2 * sin2W; + const numIm = - b1 * sinW - b2 * sin2W; const denRe = a0 + a1 * cosW + a2 * cos2W; - const denIm = - a1 * sinW - a2 * sin2W; + const denIm = - a1 * sinW - a2 * sin2W; const numMag2 = numRe * numRe + numIm * numIm; const denMag2 = denRe * denRe + denIm * denIm; return 10 * Math.log10(numMag2 / denMag2); } -function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string) { +function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: string, bgColor: string, textColor: string) { const dpr = window.devicePixelRatio || 1; const W = canvas.offsetWidth; const H = canvas.offsetHeight; - canvas.width = W * dpr; + canvas.width = W * dpr; canvas.height = H * dpr; const ctx = canvas.getContext('2d')!; ctx.scale(dpr, dpr); @@ -52,7 +53,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri padT + ((dbMax - db) / (dbMax - dbMin)) * innerH; // Background - ctx.fillStyle = '#0d0d12'; + ctx.fillStyle = bgColor; ctx.fillRect(0, 0, W, H); // Grid: dB lines @@ -64,7 +65,7 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri ctx.moveTo(padL, y); ctx.lineTo(W - padR, y); ctx.stroke(); - ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillStyle = textColor; ctx.font = '9px monospace'; ctx.textAlign = 'right'; ctx.fillText(db === 0 ? '0' : (db > 0 ? `+${db}` : `${db}`), padL - 4, y + 3); @@ -123,13 +124,70 @@ function drawCurve(canvas: HTMLCanvasElement, gains: number[], accentColor: stri ctx.stroke(); } +// ─── Custom vertical fader (no native range input) ──────────────────────────── + +const GAIN_MIN = -12, GAIN_MAX = 12; + +interface FaderProps { + value: number; + disabled: boolean; + onChange: (v: number) => void; +} + +function VerticalFader({ value, disabled, onChange }: FaderProps) { + const trackRef = useRef(null); + const dragging = useRef(false); + + const gainToPct = (g: number) => (GAIN_MAX - g) / (GAIN_MAX - GAIN_MIN); // 0=top, 1=bottom + const pctToGain = (p: number) => GAIN_MAX - p * (GAIN_MAX - GAIN_MIN); + + const updateFromY = useCallback((clientY: number) => { + const el = trackRef.current; + if (!el) return; + const rect = el.getBoundingClientRect(); + const pct = Math.max(0, Math.min(1, (clientY - rect.top) / rect.height)); + const gain = Math.round(pctToGain(pct) / 0.5) * 0.5; // snap to 0.5 dB + onChange(Math.max(GAIN_MIN, Math.min(GAIN_MAX, gain))); + }, [onChange]); + + const onPointerDown = (e: React.PointerEvent) => { + if (disabled) return; + dragging.current = true; + (e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId); + updateFromY(e.clientY); + }; + + const onPointerMove = (e: React.PointerEvent) => { + if (!dragging.current || disabled) return; + updateFromY(e.clientY); + }; + + const onPointerUp = () => { dragging.current = false; }; + + const thumbPct = gainToPct(value) * 100; + + return ( +
+
+
+
+ ); +} + // ─── Main component ─────────────────────────────────────────────────────────── export default function Equalizer() { const { t } = useTranslation(); - const gains = useEqStore(s => s.gains); - const enabled = useEqStore(s => s.enabled); - const activePreset = useEqStore(s => s.activePreset); + const gains = useEqStore(s => s.gains); + const enabled = useEqStore(s => s.enabled); + const activePreset = useEqStore(s => s.activePreset); const customPresets = useEqStore(s => s.customPresets); const { setBandGain, setEnabled, applyPreset, saveCustomPreset, deleteCustomPreset } = useEqStore(); @@ -137,13 +195,17 @@ export default function Equalizer() { const [showSave, setShowSave] = useState(false); const canvasRef = useRef(null); + const theme = useThemeStore(s => s.theme); + const redraw = useCallback(() => { const canvas = canvasRef.current; if (!canvas) return; - const accent = getComputedStyle(document.documentElement) - .getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)'; - drawCurve(canvas, gains, accent); - }, [gains]); + const style = getComputedStyle(document.documentElement); + const accent = style.getPropertyValue('--accent').trim() || 'rgb(203, 166, 247)'; + const bg = style.getPropertyValue('--bg-app').trim() || '#1e1e2e'; + const text = style.getPropertyValue('--text-muted').trim() || 'rgba(255,255,255,0.4)'; + drawCurve(canvas, gains, accent, bg, text); + }, [gains, theme]); useEffect(() => { redraw(); }, [redraw]); @@ -153,8 +215,8 @@ export default function Equalizer() { return () => ro.disconnect(); }, [redraw]); - const allPresets = [...BUILTIN_PRESETS, ...customPresets]; - const selectValue = activePreset ?? '__custom__'; + const allPresets = [...BUILTIN_PRESETS, ...customPresets]; + const selectValue = activePreset ?? '__custom__'; const isCustomSaved = activePreset && !BUILTIN_PRESETS.some(p => p.name === activePreset); const handleSave = () => { @@ -245,14 +307,10 @@ export default function Equalizer() {
- setBandGain(i, parseFloat(e.target.value))} disabled={!enabled} - aria-label={`${band.label} Hz`} + onChange={v => setBandGain(i, v)} />
{band.label} diff --git a/src/components/PlayerBar.tsx b/src/components/PlayerBar.tsx index eecaaa4f..c43b7cf1 100644 --- a/src/components/PlayerBar.tsx +++ b/src/components/PlayerBar.tsx @@ -1,12 +1,13 @@ -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import { Play, Pause, SkipBack, SkipForward, Volume2, VolumeX, Music, - Square, Repeat, Repeat1, Maximize2 + Square, Repeat, Repeat1, Maximize2, SlidersHorizontal, X } from 'lucide-react'; import { usePlayerStore } from '../store/playerStore'; import { buildCoverArtUrl, coverArtCacheKey } from '../api/subsonic'; import CachedImage from './CachedImage'; import WaveformSeek from './WaveformSeek'; +import Equalizer from './Equalizer'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -20,6 +21,7 @@ function formatTime(seconds: number): string { export default function PlayerBar() { const { t } = useTranslation(); const navigate = useNavigate(); + const [eqOpen, setEqOpen] = useState(false); const { currentTrack, isPlaying, currentTime, volume, togglePlay, next, previous, setVolume, @@ -125,6 +127,16 @@ export default function PlayerBar() { {formatTime(duration)}
+ {/* EQ Button */} + + {/* Volume */}
+ {/* EQ Popup */} + {eqOpen && ( + <> +
setEqOpen(false)} /> +
+
+ Equalizer + +
+ +
+ + )} + ); } diff --git a/src/i18n.ts b/src/i18n.ts index 87b13337..209a6d4c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -123,6 +123,7 @@ const enTranslation = { albumCount_one: '{{count}} Album', albumCount_other: '{{count}} Albums', openedInBrowser: 'Opened in browser', + featuredOn: 'Also Featured On', }, favorites: { title: 'Favorites', @@ -241,6 +242,10 @@ const enTranslation = { use: 'Use', add: 'Add', active: 'Active', + download: 'Download', + chooseDownloadFolder: 'Choose download folder', + noFolderSelected: 'No folder selected', + rememberDownloadFolder: 'Remember this folder', }, settings: { title: 'Settings', @@ -294,6 +299,7 @@ const enTranslation = { downloadsDefault: 'Default Downloads Folder', pickFolder: 'Select', pickFolderTitle: 'Select Download Folder', + clearFolder: 'Clear download folder', logout: 'Logout', aboutTitle: 'About Psysonic', aboutDesc: 'A desktop music player for Subsonic-compatible servers (Navidrome, Gonic, and others). Streams your self-hosted music library with a clean, modern interface styled after the Catppuccin colour palette.', @@ -304,6 +310,7 @@ const enTranslation = { aboutVersion: 'Version', aboutBuiltWith: 'Built with Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Developed with the support of Claude Code by Anthropic', + changelog: 'Changelog', randomMixTitle: 'Random Mix', randomMixBlacklistTitle: 'Custom Filter Keywords', randomMixBlacklistDesc: 'Songs are excluded when any keyword matches their genre, title, or album (active when the checkbox above is on).', @@ -311,6 +318,22 @@ const enTranslation = { randomMixBlacklistAdd: 'Add', randomMixBlacklistEmpty: 'No custom keywords added yet.', randomMixHardcodedTitle: 'Built-in keywords (active when checkbox is on)', + tabPlayback: 'Playback', + tabLibrary: 'Library', + tabAppearance: 'Appearance', + tabServer: 'Server', + tabAbout: 'About', + playbackTitle: 'Playback', + replayGain: 'Replay Gain', + replayGainDesc: 'Normalize track volume using ReplayGain metadata', + replayGainMode: 'Mode', + replayGainTrack: 'Track', + replayGainAlbum: 'Album', + crossfade: 'Crossfade', + crossfadeDesc: 'Fade between tracks', + crossfadeSecs: '{{n}}s', + gapless: 'Gapless Playback', + gaplessDesc: 'Pre-buffer next track to eliminate gaps between songs', }, help: { title: 'Help', @@ -564,6 +587,7 @@ const deTranslation = { albumCount_one: '{{count}} Album', albumCount_other: '{{count}} Alben', openedInBrowser: 'Im Browser geöffnet', + featuredOn: 'Auch enthalten auf', }, favorites: { title: 'Favoriten', @@ -682,6 +706,10 @@ const deTranslation = { use: 'Verwenden', add: 'Hinzufügen', active: 'Aktiv', + download: 'Herunterladen', + chooseDownloadFolder: 'Download-Ordner wählen', + noFolderSelected: 'Kein Ordner ausgewählt', + rememberDownloadFolder: 'Ordner merken', }, settings: { title: 'Einstellungen', @@ -735,6 +763,7 @@ const deTranslation = { downloadsDefault: 'Standard-Downloads-Ordner', pickFolder: 'Auswählen', pickFolderTitle: 'Download-Ordner auswählen', + clearFolder: 'Download-Ordner löschen', logout: 'Abmelden', aboutTitle: 'Über Psysonic', aboutDesc: 'Ein Desktop-Musikplayer für Subsonic-kompatible Server (Navidrome, Gonic u. a.). Streame deine selbst gehostete Musikbibliothek mit einer modernen Oberfläche im Catppuccin-Design.', @@ -745,6 +774,7 @@ const deTranslation = { aboutVersion: 'Version', aboutBuiltWith: 'Gebaut mit Tauri · React · TypeScript · Rust/rodio', aboutAiCredit: 'Mit freundlicher Unterstützung von Claude Code by Anthropic', + changelog: 'Changelog', randomMixTitle: 'Zufallsmix', randomMixBlacklistTitle: 'Eigene Filter-Keywords', randomMixBlacklistDesc: 'Songs werden ausgeschlossen, wenn ein Keyword auf Genre, Titel oder Album zutrifft (aktiv wenn die Checkbox oben an ist).', @@ -752,6 +782,22 @@ const deTranslation = { randomMixBlacklistAdd: 'Hinzufügen', randomMixBlacklistEmpty: 'Noch keine eigenen Keywords hinzugefügt.', randomMixHardcodedTitle: 'Eingebaute Keywords (aktiv wenn Checkbox an)', + tabPlayback: 'Wiedergabe', + tabLibrary: 'Bibliothek', + tabAppearance: 'Darstellung', + tabServer: 'Server', + tabAbout: 'Info', + playbackTitle: 'Wiedergabe', + replayGain: 'Replay Gain', + replayGainDesc: 'Lautstärke mit ReplayGain-Metadaten normalisieren', + replayGainMode: 'Modus', + replayGainTrack: 'Track', + replayGainAlbum: 'Album', + crossfade: 'Crossfade', + crossfadeDesc: 'Überblendung zwischen Tracks', + crossfadeSecs: '{{n}}s', + gapless: 'Nahtlose Wiedergabe', + gaplessDesc: 'Nächsten Track vorpuffern um Lücken zwischen Songs zu vermeiden', }, help: { title: 'Hilfe', diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 95f59021..c31d1ce9 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -3,6 +3,7 @@ import { useParams, useNavigate } from 'react-router-dom'; import { getAlbum, getArtist, getArtistInfo, setRating, buildCoverArtUrl, coverArtCacheKey, buildDownloadUrl, star, unstar, SubsonicSong, SubsonicAlbum } from '../api/subsonic'; import { usePlayerStore } from '../store/playerStore'; import { useAuthStore } from '../store/authStore'; +import { useDownloadModalStore } from '../store/downloadModalStore'; import { writeFile } from '@tauri-apps/plugin-fs'; import { join } from '@tauri-apps/api/path'; import AlbumCard from '../components/AlbumCard'; @@ -24,6 +25,7 @@ export default function AlbumDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const auth = useAuthStore(); + const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const playTrack = usePlayerStore(s => s.playTrack); const enqueue = usePlayerStore(s => s.enqueue); const openContextMenu = usePlayerStore(s => s.openContextMenu); @@ -106,6 +108,11 @@ export default function AlbumDetail() { const handleDownload = async () => { if (!album) return; const { name, id: albumId } = album.album; + + // Ask for folder before starting download if not already set + const folder = auth.downloadFolder || await requestDownloadFolder(); + if (!folder) return; + setDownloadProgress(0); try { const url = buildDownloadUrl(albumId); @@ -133,20 +140,9 @@ export default function AlbumDetail() { } const blob = new Blob(chunks); - if (auth.downloadFolder) { - const buffer = await blob.arrayBuffer(); - const path = await join(auth.downloadFolder, `${sanitizeFilename(name)}.zip`); - await writeFile(path, new Uint8Array(buffer)); - } else { - const blobUrl = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = blobUrl; - a.download = `${sanitizeFilename(name)}.zip`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - setTimeout(() => URL.revokeObjectURL(blobUrl), 2000); - } + const buffer = await blob.arrayBuffer(); + const path = await join(folder, `${sanitizeFilename(name)}.zip`); + await writeFile(path, new Uint8Array(buffer)); } catch (e) { console.error('Download failed:', e); setDownloadProgress(null); diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index d609b360..2d702aa1 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; +import { getArtist, getArtistInfo, getTopSongs, getSimilarSongs2, search, SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo, buildCoverArtUrl, coverArtCacheKey, star, unstar } from '../api/subsonic'; import AlbumCard from '../components/AlbumCard'; import CachedImage from '../components/CachedImage'; import { ArrowLeft, Users, ExternalLink, Star, Play, Shuffle, Radio } from 'lucide-react'; @@ -45,6 +45,7 @@ export default function ArtistDetail() { const navigate = useNavigate(); const [artist, setArtist] = useState(null); const [albums, setAlbums] = useState([]); + const [featuredAlbums, setFeaturedAlbums] = useState([]); const [topSongs, setTopSongs] = useState([]); const [info, setInfo] = useState(null); const [loading, setLoading] = useState(true); @@ -60,17 +61,45 @@ export default function ArtistDetail() { useEffect(() => { if (!id) return; setLoading(true); + let ownAlbumIds: Set; getArtist(id).then(artistData => { setArtist(artistData.artist); setAlbums(artistData.albums); setIsStarred(!!artistData.artist.starred); + ownAlbumIds = new Set(artistData.albums.map(a => a.id)); return Promise.all([ getArtistInfo(id).catch(() => null), - getTopSongs(artistData.artist.name).catch(() => []) + getTopSongs(artistData.artist.name).catch(() => []), + search(artistData.artist.name, { songCount: 500, artistCount: 0, albumCount: 0 }).catch(() => ({ songs: [], albums: [], artists: [] })), ]); - }).then(([artistInfo, songsData]) => { + }).then(([artistInfo, songsData, searchResults]) => { if (artistInfo !== undefined) setInfo(artistInfo as SubsonicArtistInfo | null); if (songsData !== undefined) setTopSongs(songsData as SubsonicSong[]); + + const featuredSongs = (searchResults.songs ?? []).filter( + song => song.artistId === id && !ownAlbumIds.has(song.albumId) + ); + const albumMap = new Map(); + featuredSongs.forEach(song => { + if (!albumMap.has(song.albumId)) { + albumMap.set(song.albumId, { + id: song.albumId, + name: song.album, + artist: song.albumArtist ?? '', + artistId: '', + coverArt: song.coverArt, + songCount: 1, + duration: song.duration, + year: song.year, + }); + } else { + const a = albumMap.get(song.albumId)!; + a.songCount++; + a.duration += song.duration; + } + }); + setFeaturedAlbums([...albumMap.values()]); + setLoading(false); }).catch(err => { console.error(err); @@ -308,6 +337,18 @@ export default function ArtistDetail() { ) : (

{t('artistDetail.noAlbums')}

)} + + {/* Also Featured On */} + {featuredAlbums.length > 0 && ( + <> +

+ {t('artistDetail.featuredOn')} +

+
+ {featuredAlbums.map(a => )} +
+ + )}
); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 300c501f..6de0f310 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,8 +1,10 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { version as appVersion } from '../../package.json'; +import changelogRaw from '../../CHANGELOG.md?raw'; import { useNavigate } from 'react-router-dom'; import { - Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle + Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, + Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play } from 'lucide-react'; import { open as openUrl } from '@tauri-apps/plugin-shell'; import { useAuthStore, ServerProfile } from '../store/authStore'; @@ -14,6 +16,8 @@ import Equalizer from '../components/Equalizer'; const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature']; +type Tab = 'playback' | 'library' | 'appearance' | 'server' | 'about'; + function AddServerForm({ onSave, onCancel }: { onSave: (data: Omit) => void; onCancel: () => void }) { const { t } = useTranslation(); const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); @@ -78,6 +82,7 @@ export default function Settings() { const navigate = useNavigate(); const { t, i18n } = useTranslation(); + const [activeTab, setActiveTab] = useState('playback'); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(false); const [newGenre, setNewGenre] = useState(''); @@ -145,378 +150,593 @@ export default function Settings() { } }; + const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [ + { id: 'playback', label: t('settings.tabPlayback'), icon: }, + { id: 'library', label: t('settings.tabLibrary'), icon: }, + { id: 'appearance', label: t('settings.tabAppearance'), icon: }, + { id: 'server', label: t('settings.tabServer'), icon: }, + { id: 'about', label: t('settings.tabAbout'), icon: }, + ]; + return (
-

{t('settings.title')}

+

{t('settings.title')}

- {/* Language */} -
-
- -

{t('settings.language')}

-
-
-
- -
-
-
+ {/* Tab navigation */} + - {/* Theme */} -
-
- -

{t('settings.theme')}

-
-
-
- -
-
-
+ {/* ── Playback ─────────────────────────────────────────────────────────── */} + {activeTab === 'playback' && ( + <> + {/* Equalizer */} +
+
+ +

{t('settings.eqTitle')}

+
+
+ +
+
- {/* Servers */} -
-
- -

{t('settings.servers')}

-
+ {/* Replay Gain + Crossfade + Gapless */} +
+
+ +

{t('settings.playbackTitle')}

+
+
+ {/* Replay Gain */} +
+
+
{t('settings.replayGain')}
+
{t('settings.replayGainDesc')}
+
+ +
+ {auth.replayGainEnabled && ( +
+ {t('settings.replayGainMode')}: + + +
+ )} - {auth.servers.length === 0 && !showAddForm ? ( -
- {t('settings.noServers')} -
- ) : ( -
- {auth.servers.map(srv => { - const isActive = srv.id === auth.activeServerId; - const status = connStatus[srv.id]; - return ( -
-
-
-
- {srv.name || srv.url} - {isActive && ( - - {t('settings.serverActive')} - - )} +
+ + {/* Crossfade */} +
+
+
{t('settings.crossfade')}
+
{t('settings.crossfadeDesc')}
+
+ +
+ {auth.crossfadeEnabled && ( +
+ auth.setCrossfadeSecs(Number(e.target.value))} + style={{ width: 120 }} + id="crossfade-secs-slider" + /> + + {t('settings.crossfadeSecs', { n: auth.crossfadeSecs })} + +
+ )} + +
+ + {/* Gapless */} +
+
+
{t('settings.gapless')}
+
{t('settings.gaplessDesc')}
+
+ +
+
+
+ + {/* Scrobbling */} +
+
+ +

{t('settings.lfmTitle')}

+
+
+
+

+ {t('settings.lfmDesc1')}{' '} + {t('settings.lfmDesc1NavidromeWebplayer')} + {' '}{t('settings.lfmDesc1b')} +

+

{t('settings.lfmDesc2')}

+
+
+
+
{t('settings.scrobbleEnabled')}
+
{t('settings.scrobbleDesc')}
+
+ +
+
+
+ + )} + + {/* ── Library ──────────────────────────────────────────────────────────── */} + {activeTab === 'library' && ( + <> + {/* Cache */} +
+
+ +

{t('settings.behavior')}

+
+
+
+
+
{t('settings.cacheTitle')}
+
{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)
+
+ auth.setMaxCacheMb(Number(e.target.value))} + style={{ width: 120 }} + id="cache-size-slider" + /> +
+
+
+ + {/* Random Mix */} +
+
+ +

{t('settings.randomMixTitle')}

+
+
+

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

+ +
{t('settings.randomMixBlacklistTitle')}
+
+ {auth.customGenreBlacklist.length === 0 ? ( + {t('settings.randomMixBlacklistEmpty')} + ) : ( + auth.customGenreBlacklist.map(genre => ( + + {genre} + + + )) + )} +
+ +
+ setNewGenre(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter' && newGenre.trim()) { + const trimmed = newGenre.trim(); + if (!auth.customGenreBlacklist.includes(trimmed)) { + auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]); + } + setNewGenre(''); + } + }} + placeholder={t('settings.randomMixBlacklistPlaceholder')} + style={{ fontSize: 13 }} + /> + +
+ +
+ +
{t('settings.randomMixHardcodedTitle')}
+
+ {AUDIOBOOK_GENRES_DISPLAY.map(genre => ( + + {genre} + + ))} +
+
+
+ + )} + + {/* ── Appearance ───────────────────────────────────────────────────────── */} + {activeTab === 'appearance' && ( + <> +
+
+ +

{t('settings.theme')}

+
+
+
+ +
+
+
+ +
+
+ +

{t('settings.language')}

+
+
+
+ +
+
+
+ + )} + + {/* ── Server ───────────────────────────────────────────────────────────── */} + {activeTab === 'server' && ( + <> +
+
+ +

{t('settings.servers')}

+
+ + {auth.servers.length === 0 && !showAddForm ? ( +
+ {t('settings.noServers')} +
+ ) : ( +
+ {auth.servers.map(srv => { + const isActive = srv.id === auth.activeServerId; + const status = connStatus[srv.id]; + return ( +
+
+
+
+ {srv.name || srv.url} + {isActive && ( + + {t('settings.serverActive')} + + )} +
+
{srv.username}@{srv.url}
+
+
+ {status === 'ok' && } + {status === 'error' && } + {status === 'testing' &&
} + + {!isActive && ( + + )} + +
-
{srv.username}@{srv.url}
-
-
- {status === 'ok' && } - {status === 'error' && } - {status === 'testing' &&
} - - {!isActive && ( - - )} -
+ ); + })} +
+ )} + + {showAddForm ? ( + setShowAddForm(false)} /> + ) : ( + + )} +
+ + {/* Downloads + Tray */} +
+
+ +

{t('settings.behavior')}

+
+
+
+
+
{t('settings.trayTitle')}
+
{t('settings.trayDesc')}
+
+ +
+ +
+ +
+
+
{t('settings.downloadsTitle')}
+
+ {auth.downloadFolder || t('settings.downloadsDefault')}
- ); - })} -
- )} - - {showAddForm ? ( - setShowAddForm(false)} /> - ) : ( - - )} -
- - {/* Equalizer */} -
-
- -

{t('settings.eqTitle')}

-
-
- -
-
- - {/* Last.fm */} -
-
- -

{t('settings.lfmTitle')}

-
-
-
-

- {t('settings.lfmDesc1')}{' '} - {t('settings.lfmDesc1NavidromeWebplayer')} - {' '}{t('settings.lfmDesc1b')} -

-

{t('settings.lfmDesc2')}

-
- -
-
-
{t('settings.scrobbleEnabled')}
-
{t('settings.scrobbleDesc')}
-
- -
-
-
- - {/* App Behavior */} -
-
- -

{t('settings.behavior')}

-
-
-
-
-
{t('settings.trayTitle')}
-
{t('settings.trayDesc')}
-
- -
- -
- -
-
-
{t('settings.cacheTitle')}
-
{t('settings.cacheDesc')} ({auth.maxCacheMb} MB)
-
- auth.setMaxCacheMb(Number(e.target.value))} - style={{ width: 120 }} - id="cache-size-slider" - /> -
-
- -
-
-
{t('settings.downloadsTitle')}
-
- {auth.downloadFolder || t('settings.downloadsDefault')} +
+ {auth.downloadFolder && ( + + )} + +
-
+ + )} + + {/* ── About ────────────────────────────────────────────────────────────── */} + {activeTab === 'about' && ( + <> +
+
+ +

{t('settings.aboutTitle')}

+
+
+
+ Psysonic +
+
+ Psysonic +
+
+ {t('settings.aboutVersion')} {appVersion} +
+
+
+ +

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

+

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

+ +
+ +
+
+ {t('settings.aboutLicense')} + {t('settings.aboutLicenseText')} +
+
+ Stack + {t('settings.aboutBuiltWith')} +
+
+ AI + {t('settings.aboutAiCredit')} +
+
+ + +
+
+ + + +
+ -
-
- - - {/* Random Mix */} -
-
- -

{t('settings.randomMixTitle')}

-
-
- {/* Description */} -

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

- - {/* Custom blacklist chips */} -
{t('settings.randomMixBlacklistTitle')}
-
- {auth.customGenreBlacklist.length === 0 ? ( - {t('settings.randomMixBlacklistEmpty')} - ) : ( - auth.customGenreBlacklist.map(genre => ( - - {genre} - - - )) - )} -
- - {/* Add input */} -
- setNewGenre(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter' && newGenre.trim()) { - const trimmed = newGenre.trim(); - if (!auth.customGenreBlacklist.includes(trimmed)) { - auth.setCustomGenreBlacklist([...auth.customGenreBlacklist, trimmed]); - } - setNewGenre(''); - } - }} - placeholder={t('settings.randomMixBlacklistPlaceholder')} - style={{ fontSize: 13 }} - /> - -
- -
- - {/* Hardcoded list */} -
{t('settings.randomMixHardcodedTitle')}
-
- {AUDIOBOOK_GENRES_DISPLAY.map(genre => ( - - {genre} - - ))} -
-
-
- - {/* Logout */} -
- -
- - {/* About */} -
-
- -

{t('settings.aboutTitle')}

-
-
-
- Psysonic -
-
- Psysonic -
-
- {t('settings.aboutVersion')} {appVersion} -
-
-
- -

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

-

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

- -
- -
-
- {t('settings.aboutLicense')} - {t('settings.aboutLicenseText')} -
-
- Stack - {t('settings.aboutBuiltWith')} -
-
- AI - {t('settings.aboutAiCredit')} -
-
- - -
-
+ + + )}
); } + +// ─── Changelog renderer ─────────────────────────────────────────────────────── + +function renderInline(text: string): React.ReactNode[] { + // Splits on **bold**, *italic*, `code` and renders each part. + const parts = text.split(/(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g); + return parts.map((part, i) => { + if (part.startsWith('**') && part.endsWith('**')) + return {part.slice(2, -2)}; + if (part.startsWith('*') && part.endsWith('*')) + return {part.slice(1, -1)}; + if (part.startsWith('`') && part.endsWith('`')) + return {part.slice(1, -1)}; + return part; + }); +} + +function ChangelogSection() { + const { t } = useTranslation(); + + const versions = useMemo(() => { + const blocks = changelogRaw.split(/\n(?=## \[)/).filter(b => b.startsWith('## [')); + return blocks.map(block => { + const lines = block.split('\n'); + const headerLine = lines[0]; // e.g. "## [1.5.0] - 2026-03-18" + const versionMatch = headerLine.match(/## \[([^\]]+)\]/); + const dateMatch = headerLine.match(/- (\d{4}-\d{2}-\d{2})/); + const version = versionMatch?.[1] ?? ''; + const date = dateMatch?.[1] ?? ''; + + // Parse the rest into rendered lines + const body = lines.slice(1).join('\n').trim(); + return { version, date, body }; + }); + }, []); + + return ( +
+
+ +

{t('settings.changelog')}

+
+
+ {versions.map(({ version, date, body }) => ( +
+ + v{version} + {date} + +
+ {body.split('\n').map((line, i) => { + if (line.startsWith('### ')) { + return
{renderInline(line.slice(4))}
; + } + if (line.startsWith('#### ')) { + return
{renderInline(line.slice(5))}
; + } + if (line.startsWith('- ')) { + return
{renderInline(line.slice(2))}
; + } + if (line.trim() === '') return null; + return
{renderInline(line)}
; + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 1f4d3eb6..da8173e5 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -27,6 +27,11 @@ interface AuthState { downloadFolder: string; excludeAudiobooks: boolean; customGenreBlacklist: string[]; + replayGainEnabled: boolean; + replayGainMode: 'track' | 'album'; + crossfadeEnabled: boolean; + crossfadeSecs: number; + gaplessEnabled: boolean; // Status isLoggedIn: boolean; @@ -48,6 +53,11 @@ interface AuthState { setDownloadFolder: (v: string) => void; setExcludeAudiobooks: (v: boolean) => void; setCustomGenreBlacklist: (v: string[]) => void; + setReplayGainEnabled: (v: boolean) => void; + setReplayGainMode: (v: 'track' | 'album') => void; + setCrossfadeEnabled: (v: boolean) => void; + setCrossfadeSecs: (v: number) => void; + setGaplessEnabled: (v: boolean) => void; logout: () => void; // Derived @@ -74,6 +84,11 @@ export const useAuthStore = create()( downloadFolder: '', excludeAudiobooks: false, customGenreBlacklist: [], + replayGainEnabled: false, + replayGainMode: 'track', + crossfadeEnabled: false, + crossfadeSecs: 3, + gaplessEnabled: false, isLoggedIn: false, isConnecting: false, connectionError: null, @@ -117,6 +132,11 @@ export const useAuthStore = create()( setDownloadFolder: (v) => set({ downloadFolder: v }), setExcludeAudiobooks: (v) => set({ excludeAudiobooks: v }), setCustomGenreBlacklist: (v) => set({ customGenreBlacklist: v }), + setReplayGainEnabled: (v) => set({ replayGainEnabled: v }), + setReplayGainMode: (v) => set({ replayGainMode: v }), + setCrossfadeEnabled: (v) => set({ crossfadeEnabled: v }), + setCrossfadeSecs: (v) => set({ crossfadeSecs: v }), + setGaplessEnabled: (v) => set({ gaplessEnabled: v }), logout: () => set({ isLoggedIn: false }), diff --git a/src/store/downloadModalStore.ts b/src/store/downloadModalStore.ts new file mode 100644 index 00000000..3879bbf9 --- /dev/null +++ b/src/store/downloadModalStore.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand'; + +// Module-level callback — not in Zustand state to avoid serialization issues +let _resolve: ((folder: string | null) => void) | null = null; + +interface DownloadModalStore { + isOpen: boolean; + folder: string; + remember: boolean; + requestFolder: () => Promise; + setFolder: (f: string) => void; + setRemember: (r: boolean) => void; + confirm: (setDownloadFolder: (v: string) => void) => void; + cancel: () => void; +} + +export const useDownloadModalStore = create((set, get) => ({ + isOpen: false, + folder: '', + remember: false, + + requestFolder: () => + new Promise(resolve => { + _resolve = resolve; + set({ isOpen: true, folder: '', remember: false }); + }), + + setFolder: (folder) => set({ folder }), + setRemember: (remember) => set({ remember }), + + confirm: (setDownloadFolder) => { + const { folder, remember } = get(); + if (!folder) return; + if (remember) setDownloadFolder(folder); + _resolve?.(folder); + _resolve = null; + set({ isOpen: false }); + }, + + cancel: () => { + _resolve?.(null); + _resolve = null; + set({ isOpen: false }); + }, +})); diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 7dd1fb1e..d55eb95f 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -19,6 +19,30 @@ export interface Track { bitRate?: number; suffix?: string; userRating?: number; + replayGainTrackDb?: number; + replayGainAlbumDb?: number; + replayGainPeak?: number; +} + +export function songToTrack(song: SubsonicSong): Track { + return { + id: song.id, + title: song.title, + artist: song.artist, + album: song.album, + albumId: song.albumId, + artistId: song.artistId, + duration: song.duration, + coverArt: song.coverArt, + track: song.track, + year: song.year, + bitRate: song.bitRate, + suffix: song.suffix, + userRating: song.userRating, + replayGainTrackDb: song.replayGain?.trackGain, + replayGainAlbumDb: song.replayGain?.albumGain, + replayGainPeak: song.replayGain?.trackPeak, + }; } interface PlayerState { @@ -125,6 +149,20 @@ function handleAudioProgress(current_time: number, duration: number) { const { scrobblingEnabled } = useAuthStore.getState(); if (scrobblingEnabled) scrobbleSong(track.id, Date.now()); } + + // Gapless preload: buffer next track when 30s remain + const { gaplessEnabled } = useAuthStore.getState(); + if (gaplessEnabled && dur - current_time < 30 && dur - current_time > 0) { + const { queue, queueIndex, repeatMode } = store; + const nextIdx = queueIndex + 1; + const nextTrack = repeatMode === 'one' + ? track + : (nextIdx < queue.length ? queue[nextIdx] : (repeatMode === 'all' ? queue[0] : null)); + if (nextTrack && nextTrack.id !== track.id) { + const nextUrl = buildStreamUrl(nextTrack.id); + invoke('audio_preload', { url: nextUrl, durationHint: nextTrack.duration }).catch(() => {}); + } + } } function handleAudioEnded() { @@ -167,7 +205,20 @@ export function initAudioListeners(): () => void { listen('audio:error', ({ payload }) => handleAudioError(payload)), ]; + // Initial sync of crossfade settings to Rust audio engine on startup. + const { crossfadeEnabled, crossfadeSecs } = useAuthStore.getState(); + invoke('audio_set_crossfade', { enabled: crossfadeEnabled, secs: crossfadeSecs }).catch(() => {}); + + // Keep crossfade settings in sync whenever auth store changes. + const unsubAuth = useAuthStore.subscribe((state) => { + invoke('audio_set_crossfade', { + enabled: state.crossfadeEnabled, + secs: state.crossfadeSecs, + }).catch(() => {}); + }); + return () => { + unsubAuth(); pending.forEach(p => p.then(unlisten => unlisten())); }; } @@ -238,10 +289,17 @@ export const usePlayerStore = create()( }); const url = buildStreamUrl(track.id); + const authState = useAuthStore.getState(); + const replayGainDb = authState.replayGainEnabled + ? (authState.replayGainMode === 'album' ? track.replayGainAlbumDb : track.replayGainTrackDb) ?? null + : null; + const replayGainPeak = authState.replayGainEnabled ? (track.replayGainPeak ?? null) : null; invoke('audio_play', { url, volume: state.volume, durationHint: track.duration, + replayGainDb, + replayGainPeak, }).catch((err: unknown) => { if (playGeneration !== gen) return; console.error('[psysonic] audio_play failed:', err); @@ -277,10 +335,17 @@ export const usePlayerStore = create()( const gen = ++playGeneration; const vol = get().volume; set({ isPlaying: true }); + const authStateCold = useAuthStore.getState(); + const replayGainDbCold = authStateCold.replayGainEnabled + ? (authStateCold.replayGainMode === 'album' ? currentTrack.replayGainAlbumDb : currentTrack.replayGainTrackDb) ?? null + : null; + const replayGainPeakCold = authStateCold.replayGainEnabled ? (currentTrack.replayGainPeak ?? null) : null; invoke('audio_play', { url: buildStreamUrl(currentTrack.id), volume: vol, durationHint: currentTrack.duration, + replayGainDb: replayGainDbCold, + replayGainPeak: replayGainPeakCold, }).then(() => { if (playGeneration === gen && currentTime > 1) { invoke('audio_seek', { seconds: currentTime }).catch(console.error); diff --git a/src/styles/components.css b/src/styles/components.css index 38c07bfb..09e21525 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -25,8 +25,14 @@ transform: scale(1.05); filter: blur(12px); } -.hero:hover .hero-bg { filter: blur(8px); } -.hero:hover .hero-bg { transform: scale(1); } + +.hero:hover .hero-bg { + filter: blur(8px); +} + +.hero:hover .hero-bg { + transform: scale(1); +} .hero-dots { position: absolute; @@ -37,6 +43,7 @@ gap: 6px; z-index: 10; } + .hero-dot { width: 8px; height: 8px; @@ -47,23 +54,26 @@ cursor: pointer; transition: all 0.3s ease; } -.hero-dot:hover:not(.hero-dot-active) { background: rgba(255, 255, 255, 0.6); } -.hero-dot-active { width: 24px; background: rgba(255, 255, 255, 0.9); } + +.hero-dot:hover:not(.hero-dot-active) { + background: rgba(255, 255, 255, 0.6); +} + +.hero-dot-active { + width: 24px; + background: rgba(255, 255, 255, 0.9); +} .hero-overlay { position: absolute; inset: 0; - background: linear-gradient( - to right, - rgba(0, 0, 0, 0.78) 0%, - rgba(0, 0, 0, 0.45) 50%, - rgba(0, 0, 0, 0.18) 100% - ), - linear-gradient( - to top, - rgba(0, 0, 0, 0.88) 0%, - transparent 60% - ); + background: linear-gradient(to right, + rgba(0, 0, 0, 0.78) 0%, + rgba(0, 0, 0, 0.45) 50%, + rgba(0, 0, 0, 0.18) 100%), + linear-gradient(to top, + rgba(0, 0, 0, 0.88) 0%, + transparent 60%); } .hero-content { @@ -79,10 +89,10 @@ width: 220px; height: 220px; border-radius: var(--radius-md); - box-shadow: 0 4px 20px rgba(0,0,0,0.6); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6); object-fit: cover; flex-shrink: 0; - border: 1px solid rgba(255,255,255,0.1); + border: 1px solid rgba(255, 255, 255, 0.1); } .hero-text { @@ -107,7 +117,7 @@ color: #ffffff; margin-bottom: var(--space-1); line-height: 1.2; - text-shadow: 0 2px 12px rgba(0,0,0,0.6); + text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6); max-width: 500px; } @@ -136,10 +146,16 @@ transition: all var(--transition-fast); width: fit-content; } -.hero-play-btn:hover { background: var(--ctp-lavender); transform: scale(1.02); box-shadow: var(--shadow-glow); } + +.hero-play-btn:hover { + background: var(--ctp-lavender); + transform: scale(1.02); + box-shadow: var(--shadow-glow); +} /* ─ Section titles ─ */ -.section-title, .page-title { +.section-title, +.page-title { font-family: var(--font-display); font-size: 20px; font-weight: 700; @@ -156,7 +172,12 @@ overflow: hidden; transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); } -.album-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border); } + +.album-card:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--border); +} .album-card-cover { position: relative; @@ -164,13 +185,17 @@ overflow: hidden; background: var(--bg-hover); } + .album-card-cover img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--transition-slow); } -.album-card:hover .album-card-cover img { transform: scale(1.04); } + +.album-card:hover .album-card-cover img { + transform: scale(1.04); +} .album-card-cover-placeholder { width: 100%; @@ -192,11 +217,13 @@ overflow: hidden; transition: transform var(--transition-base), box-shadow var(--transition-base), border-color var(--transition-base); } + .artist-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--border); } + .artist-card-avatar { position: relative; aspect-ratio: 1; @@ -207,13 +234,18 @@ justify-content: center; color: var(--text-muted); } + .artist-card-avatar img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--transition-slow); } -.artist-card:hover .artist-card-avatar img { transform: scale(1.04); } + +.artist-card:hover .artist-card-avatar img { + transform: scale(1.04); +} + .artist-card-avatar-initial { aspect-ratio: 1; display: flex; @@ -225,6 +257,7 @@ width: calc(100% - 2 * var(--space-4)); margin: var(--space-3) auto var(--space-3); } + .artist-card-avatar-initial span { font-size: 3rem; font-weight: 800; @@ -232,12 +265,14 @@ line-height: 1; user-select: none; } + .artist-card-info { padding: var(--space-3) var(--space-3) var(--space-2); display: flex; flex-direction: column; gap: 2px; } + .artist-card-name { font-weight: 600; font-size: 13px; @@ -246,6 +281,7 @@ text-overflow: ellipsis; white-space: nowrap; } + .artist-card-meta { font-size: 12px; color: var(--text-secondary); @@ -284,11 +320,13 @@ transition: all var(--transition-fast); cursor: pointer; } + .album-row-nav .nav-btn:hover:not(.disabled) { background: var(--bg-hover); color: var(--text-primary); border-color: var(--border); } + .album-row-nav .nav-btn.disabled { opacity: 0.3; cursor: default; @@ -308,8 +346,15 @@ /* Hide scrollbar for Webkit */ -webkit-overflow-scrolling: touch; } -.album-grid::-webkit-scrollbar { display: none; } -.album-grid { -ms-overflow-style: none; scrollbar-width: none; } + +.album-grid::-webkit-scrollbar { + display: none; +} + +.album-grid { + -ms-overflow-style: none; + scrollbar-width: none; +} .album-grid-wrap { display: grid; @@ -319,7 +364,9 @@ @media (min-width: 1024px) { - .album-grid-wrap { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); } + .album-grid-wrap { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } } .album-grid .album-card, @@ -343,6 +390,7 @@ scroll-snap-align: start; transition: all var(--transition-base); } + .album-card-more:hover { background: var(--ctp-surface0); color: var(--text-primary); @@ -353,14 +401,17 @@ .album-card-play-overlay { position: absolute; inset: 0; - background: rgba(0,0,0,0.4); + background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity var(--transition-base); } -.album-card:hover .album-card-play-overlay { opacity: 1; } + +.album-card:hover .album-card-play-overlay { + opacity: 1; +} .album-card-details-btn { background: var(--accent); @@ -375,27 +426,77 @@ font-size: 13px; cursor: pointer; transition: transform var(--transition-fast), background var(--transition-fast); - box-shadow: 0 4px 16px rgba(0,0,0,0.6); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.6); } -.album-card-details-btn:hover { - transform: scale(1.05); + +.album-card-details-btn:hover { + transform: scale(1.05); background: var(--accent); } .album-card-info { padding: var(--space-3) var(--space-4) var(--space-4); } -.album-card-title { font-weight: 600; font-size: 13px; color: var(--text-primary); margin-bottom: 2px; } -.album-card-artist { font-size: 12px; color: var(--text-secondary); } -.album-card-year { font-size: 11px; color: var(--text-muted); margin-top: 2px; } + +.album-card-title { + font-weight: 600; + font-size: 13px; + color: var(--text-primary); + margin-bottom: 2px; +} + +.album-card-artist { + font-size: 12px; + color: var(--text-secondary); +} + +.album-card-year { + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} /* ─ Live Search ─ */ -.live-search { position: relative; min-width: 280px; max-width: 420px; flex: 1; } -.live-search-input-wrap { position: relative; display: flex; align-items: center; } -.live-search-icon { position: absolute; left: 12px; color: var(--text-muted); pointer-events: none; display: flex; align-items: center; } -.live-search-field { padding-left: 36px !important; padding-right: 32px !important; border-radius: var(--radius-full) !important; } -.live-search-clear { position: absolute; right: 12px; font-size: 18px; color: var(--text-muted); line-height: 1; transition: color var(--transition-fast); } -.live-search-clear:hover { color: var(--text-primary); } +.live-search { + position: relative; + min-width: 280px; + max-width: 420px; + flex: 1; +} + +.live-search-input-wrap { + position: relative; + display: flex; + align-items: center; +} + +.live-search-icon { + position: absolute; + left: 12px; + color: var(--text-muted); + pointer-events: none; + display: flex; + align-items: center; +} + +.live-search-field { + padding-left: 36px !important; + padding-right: 32px !important; + border-radius: var(--radius-full) !important; +} + +.live-search-clear { + position: absolute; + right: 12px; + font-size: 18px; + color: var(--text-muted); + line-height: 1; + transition: color var(--transition-fast); +} + +.live-search-clear:hover { + color: var(--text-primary); +} .live-search-dropdown { position: absolute; @@ -412,8 +513,13 @@ animation: fadeIn 150ms ease both; } -.search-section { padding: var(--space-2) 0; } -.search-section + .search-section { border-top: 1px solid var(--border-subtle); } +.search-section { + padding: var(--space-2) 0; +} + +.search-section+.search-section { + border-top: 1px solid var(--border-subtle); +} .search-section-label { display: flex; @@ -437,19 +543,61 @@ transition: background var(--transition-fast); border-radius: 0; } + .search-result-item:hover, -.search-result-item.active { background: var(--bg-hover); } -.search-result-icon { width: 32px; height: 32px; border-radius: var(--radius-sm); background: var(--bg-hover); display: flex; align-items: center; justify-content: center; color: var(--text-muted); flex-shrink: 0; } -.search-result-thumb { width: 32px; height: 32px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; } -.search-result-name { font-size: 13px; font-weight: 500; color: var(--text-primary); } -.search-result-sub { font-size: 11px; color: var(--text-secondary); margin-top: 2px; } -.search-empty { padding: var(--space-4); text-align: center; color: var(--text-muted); font-size: 13px; } +.search-result-item.active { + background: var(--bg-hover); +} + +.search-result-icon { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + background: var(--bg-hover); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.search-result-thumb { + width: 32px; + height: 32px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; +} + +.search-result-name { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); +} + +.search-result-sub { + font-size: 11px; + color: var(--text-secondary); + margin-top: 2px; +} + +.search-empty { + padding: var(--space-4); + text-align: center; + color: var(--text-muted); + font-size: 13px; +} /* ─ Album Detail ─ */ -.album-detail { display: flex; flex-direction: column; gap: 0; } -.album-detail-header { +.album-detail { + display: flex; + flex-direction: column; + gap: 0; +} + +.album-detail-header { position: relative; - padding: var(--space-6) var(--space-6) 0; + padding: var(--space-6) var(--space-6) 0; overflow: hidden; } @@ -459,7 +607,8 @@ background-size: cover; background-position: center; filter: blur(20px) brightness(0.6); - transform: scale(1.1); /* Hide blur edges */ + transform: scale(1.1); + /* Hide blur edges */ z-index: 0; opacity: 0.8; transition: opacity var(--transition-slow); @@ -468,11 +617,9 @@ .album-detail-overlay { position: absolute; inset: 0; - background: linear-gradient( - to bottom, - rgba(30, 30, 46, 0.5) 0%, - var(--bg-app) 100% - ); + background: linear-gradient(to bottom, + rgba(30, 30, 46, 0.5) 0%, + var(--bg-app) 100%); z-index: 0; } @@ -482,6 +629,7 @@ align-items: flex-start; padding: var(--space-4) 0 var(--space-6); } + .album-detail-cover { width: clamp(120px, 15vw, 200px); height: clamp(120px, 15vw, 200px); @@ -490,6 +638,7 @@ box-shadow: var(--shadow-lg); flex-shrink: 0; } + .album-cover-placeholder { display: flex; align-items: center; @@ -498,9 +647,28 @@ font-size: 48px; color: var(--text-muted); } -.album-detail-meta { min-width: 0; flex: 1; } -.album-detail-title { font-family: var(--font-display); font-size: clamp(20px, 3vw, 32px); font-weight: 800; color: var(--text-primary); line-height: 1.1; margin: var(--space-2) 0 var(--space-1); overflow-wrap: break-word; } -.album-detail-artist { font-size: clamp(13px, 1.5vw, 16px); color: var(--accent); font-weight: 600; } + +.album-detail-meta { + min-width: 0; + flex: 1; +} + +.album-detail-title { + font-family: var(--font-display); + font-size: clamp(20px, 3vw, 32px); + font-weight: 800; + color: var(--text-primary); + line-height: 1.1; + margin: var(--space-2) 0 var(--space-1); + overflow-wrap: break-word; +} + +.album-detail-artist { + font-size: clamp(13px, 1.5vw, 16px); + color: var(--accent); + font-weight: 600; +} + .album-detail-artist-link { background: none; border: none; @@ -512,20 +680,65 @@ text-decoration: none; transition: color var(--transition-fast), text-decoration var(--transition-fast); } + .album-detail-artist-link:hover { color: var(--text-primary); text-decoration: underline; } -.album-detail-info { display: flex; flex-wrap: wrap; gap: var(--space-2); color: var(--text-muted); font-size: 13px; margin: var(--space-2) 0 var(--space-4); } -.album-detail-actions { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; row-gap: var(--space-2); } -.album-detail-actions-primary { display: flex; gap: 8px; flex-wrap: wrap; } -.album-detail-content { position: relative; z-index: 1; } -.album-detail-badge { margin-bottom: 0.5rem; } -.album-detail-back { margin-bottom: 1rem; gap: 6px; } -.album-info-dot { margin: 0 4px; } -.album-related { padding: 0 var(--space-6) var(--space-8); } -.album-related-divider { border-top: 1px solid var(--border-subtle); margin-bottom: 2rem; } -.album-related-title { margin-bottom: 1rem; } + +.album-detail-info { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + color: var(--text-muted); + font-size: 13px; + margin: var(--space-2) 0 var(--space-4); +} + +.album-detail-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; + row-gap: var(--space-2); +} + +.album-detail-actions-primary { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.album-detail-content { + position: relative; + z-index: 1; +} + +.album-detail-badge { + margin-bottom: 0.5rem; +} + +.album-detail-back { + margin-bottom: 1rem; + gap: 6px; +} + +.album-info-dot { + margin: 0 4px; +} + +.album-related { + padding: 0 var(--space-6) var(--space-8); +} + +.album-related-divider { + border-top: 1px solid var(--border-subtle); + margin-bottom: 2rem; +} + +.album-related-title { + margin-bottom: 1rem; +} .download-hint { display: flex; @@ -554,6 +767,7 @@ color: var(--text-secondary); font-size: 12px; } + .download-progress-bar { flex: 1; height: 4px; @@ -561,21 +775,73 @@ border-radius: 2px; overflow: hidden; } + .download-progress-fill { height: 100%; background: linear-gradient(90deg, var(--ctp-mauve), var(--ctp-blue)); border-radius: 2px; transition: width 0.2s ease; } + .download-progress-pct { min-width: 28px; text-align: right; font-variant-numeric: tabular-nums; } +/* ─ Download folder modal ─ */ +.download-folder-pick-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg-input, var(--bg-sidebar)); + margin-bottom: 12px; +} + +.download-folder-path { + flex: 1; + font-size: 13px; + color: var(--text-primary); + word-break: break-all; + min-width: 0; +} + +.download-folder-path:empty::before, +.download-folder-path[data-empty]::before { + color: var(--text-muted); +} + +.download-remember-row { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + margin-bottom: 4px; +} + +.download-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 12px; + border-top: 1px solid var(--border); + margin-top: 4px; +} + /* ─ Tracklist ─ */ -.tracklist { padding: 0 var(--space-6) var(--space-6); } -.col-center { text-align: center; } +.tracklist { + padding: 0 var(--space-6) var(--space-6); +} + +.col-center { + text-align: center; +} .tracklist-header { display: grid; @@ -591,6 +857,7 @@ border-bottom: 1px solid var(--border-subtle); margin-bottom: var(--space-2); } + .tracklist-header.tracklist-va { grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px; } @@ -605,6 +872,7 @@ cursor: pointer; transition: background var(--transition-fast); } + .track-row.track-row-va { grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px; } @@ -617,9 +885,11 @@ padding: var(--space-2) var(--space-3); margin-top: var(--space-1); } + .tracklist-total.tracklist-va { grid-template-columns: 36px minmax(100px, 2fr) minmax(80px, 1.2fr) 70px 80px 60px 120px; } + .tracklist-total-label { grid-column: 1 / 5; text-align: right; @@ -629,6 +899,7 @@ text-transform: uppercase; letter-spacing: 0.06em; } + .tracklist-total-value { grid-column: 5 / 6; text-align: center; @@ -637,17 +908,46 @@ color: var(--text-secondary); font-variant-numeric: tabular-nums; } -.tracklist-total.tracklist-va .tracklist-total-label { grid-column: 1 / 6; } -.tracklist-total.tracklist-va .tracklist-total-value { grid-column: 6 / 7; } -.track-row:hover, -.track-row.context-active { background: var(--bg-hover); } -.track-row.active { background: var(--accent-dim); animation: track-pulse 3s ease-in-out infinite; } -.track-row.active:hover { background: var(--accent-dim); animation: none; } -@keyframes track-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } + +.tracklist-total.tracklist-va .tracklist-total-label { + grid-column: 1 / 6; +} + +.tracklist-total.tracklist-va .tracklist-total-value { + grid-column: 6 / 7; +} + +.track-row:hover, +.track-row.context-active { + background: var(--bg-hover); +} + +.track-row.active { + background: var(--accent-dim); + animation: track-pulse 3s ease-in-out infinite; +} + +.track-row.active:hover { + background: var(--accent-dim); + animation: none; +} + +@keyframes track-pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.6; + } +} + +.track-row>* { + padding-top: 6px; + padding-bottom: 6px; } -.track-row > * { padding-top: 6px; padding-bottom: 6px; } .track-num { display: flex; @@ -664,6 +964,7 @@ width: 14px; height: 13px; } + .eq-bar { flex: 1; height: 100%; @@ -673,12 +974,32 @@ transform: scaleY(0.25); animation: eq-bounce 1.1s ease-in-out infinite; } -.eq-bar:nth-child(1) { animation-delay: 0s; animation-duration: 1.0s; } -.eq-bar:nth-child(2) { animation-delay: 0.18s; animation-duration: 1.3s; } -.eq-bar:nth-child(3) { animation-delay: 0.35s; animation-duration: 0.9s; } + +.eq-bar:nth-child(1) { + animation-delay: 0s; + animation-duration: 1.0s; +} + +.eq-bar:nth-child(2) { + animation-delay: 0.18s; + animation-duration: 1.3s; +} + +.eq-bar:nth-child(3) { + animation-delay: 0.35s; + animation-duration: 0.9s; +} + @keyframes eq-bounce { - 0%, 100% { transform: scaleY(0.25); } - 50% { transform: scaleY(1); } + + 0%, + 100% { + transform: scaleY(0.25); + } + + 50% { + transform: scaleY(1); + } } /* CD / Disc separator */ @@ -696,14 +1017,48 @@ margin-top: var(--space-3); border-top: 1px solid var(--border-subtle); } -.disc-header:first-child { border-top: none; margin-top: 0; } -.disc-icon { font-size: 16px; } -.track-num { font-size: 13px; color: var(--text-muted); font-variant-numeric: tabular-nums; } -.track-info { min-width: 0; } -.track-title { font-size: 13px; font-weight: 500; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; } -.track-artist-cell { min-width: 0; display: flex; align-items: flex-start; } -.track-artist { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.disc-header:first-child { + border-top: none; + margin-top: 0; +} + +.disc-icon { + font-size: 16px; +} + +.track-num { + font-size: 13px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.track-info { + min-width: 0; +} + +.track-title { + font-size: 13px; + font-weight: 500; + color: var(--text-primary); + overflow-wrap: break-word; + word-break: break-word; +} + +.track-artist-cell { + min-width: 0; + display: flex; + align-items: flex-start; +} + +.track-artist { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .track-codec { display: block; font-size: 10px; @@ -715,12 +1070,37 @@ overflow: hidden; text-overflow: ellipsis; } -.track-size { color: var(--ctp-overlay0); } -.track-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; text-align: center; } -.track-meta { display: flex; align-items: center; } -.track-meta .track-codec { margin-top: 0; } -.track-star-cell { display: flex; justify-content: center; } -.track-star-btn { padding: 4px; height: auto; min-height: unset; } + +.track-size { + color: var(--ctp-overlay0); +} + +.track-duration { + font-size: 12px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; + text-align: center; +} + +.track-meta { + display: flex; + align-items: center; +} + +.track-meta .track-codec { + margin-top: 0; +} + +.track-star-cell { + display: flex; + justify-content: center; +} + +.track-star-btn { + padding: 4px; + height: auto; + min-height: unset; +} /* ─ Modal ─ */ .modal-overlay { @@ -735,6 +1115,7 @@ z-index: 9999; animation: fadeIn 150ms ease both; } + .modal-content { background: var(--ctp-surface0); border: 1px solid var(--border); @@ -748,12 +1129,33 @@ animation: fadeIn 200ms ease both; box-shadow: var(--shadow-lg); } -.modal-close { position: absolute; top: var(--space-4); right: var(--space-4); color: var(--text-muted); } -.modal-close:hover { color: var(--text-primary); } -.modal-title { margin-bottom: 1rem; font-family: var(--font-display); } -.artist-bio { font-size: 14px; line-height: 1.7; color: var(--text-secondary); } -.artist-bio a { color: var(--accent); text-decoration: underline; } +.modal-close { + position: absolute; + top: var(--space-4); + right: var(--space-4); + color: var(--text-muted); +} + +.modal-close:hover { + color: var(--text-primary); +} + +.modal-title { + margin-bottom: 1rem; + font-family: var(--font-display); +} + +.artist-bio { + font-size: 14px; + line-height: 1.7; + color: var(--text-secondary); +} + +.artist-bio a { + color: var(--accent); + text-decoration: underline; +} /* Artist Detail Page */ .artist-detail-header { @@ -762,6 +1164,7 @@ gap: 2rem; margin-bottom: 2rem; } + .artist-detail-avatar { width: 160px; height: 160px; @@ -775,16 +1178,19 @@ justify-content: center; border: 1px solid var(--border-subtle); } + .artist-detail-meta { flex: 1; min-width: 0; padding-top: 0.5rem; } + .artist-detail-links { display: flex; gap: 0.5rem; flex-wrap: wrap; } + .artist-ext-link { display: inline-flex; align-items: center; @@ -799,6 +1205,7 @@ cursor: pointer; transition: all var(--transition-fast); } + .artist-ext-link:hover { background: var(--bg-hover); color: var(--text-primary); @@ -809,56 +1216,288 @@ .artist-bio-section { margin-bottom: 0.5rem; } + .artist-bio-text { font-size: 14px; line-height: 1.75; color: var(--text-secondary); max-width: 100%; } -.artist-bio-text a { color: var(--accent); text-decoration: underline; } + +.artist-bio-text a { + color: var(--accent); + text-decoration: underline; +} /* ─ Artists Page ─ */ -.letter-heading { font-size: 13px; font-weight: 700; color: var(--text-muted); letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: var(--space-2); padding: var(--space-2) 0; border-bottom: 1px solid var(--border-subtle); } -.artist-list { display: flex; flex-direction: column; gap: 2px; } -.artist-row { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); transition: background var(--transition-fast); width: 100%; text-align: left; } -.artist-row:hover { background: var(--bg-hover); } -.artist-avatar { width: 38px; height: 38px; border-radius: 50%; background: var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent); flex-shrink: 0; } -.artist-avatar-initial { background: var(--bg-card); border: 2px solid; } -.artist-avatar-initial span { font-size: 1rem; font-weight: 700; font-family: var(--font-display); line-height: 1; user-select: none; } -.artist-name { font-size: 14px; font-weight: 500; color: var(--text-primary); } -.artist-meta { font-size: 12px; color: var(--text-muted); margin-top: 2px; } +.letter-heading { + font-size: 13px; + font-weight: 700; + color: var(--text-muted); + letter-spacing: 0.08em; + text-transform: uppercase; + margin-bottom: var(--space-2); + padding: var(--space-2) 0; + border-bottom: 1px solid var(--border-subtle); +} + +.artist-list { + display: flex; + flex-direction: column; + gap: 2px; +} + +.artist-row { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-md); + transition: background var(--transition-fast); + width: 100%; + text-align: left; +} + +.artist-row:hover { + background: var(--bg-hover); +} + +.artist-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: var(--accent-dim); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent); + flex-shrink: 0; +} + +.artist-avatar-initial { + background: var(--bg-card); + border: 2px solid; +} + +.artist-avatar-initial span { + font-size: 1rem; + font-weight: 700; + font-family: var(--font-display); + line-height: 1; + user-select: none; +} + +.artist-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.artist-meta { + font-size: 12px; + color: var(--text-muted); + margin-top: 2px; +} /* ─ Years Page ─ */ -.decade-heading { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--text-primary); margin-bottom: var(--space-3); } -.year-section { margin-bottom: var(--space-3); } -.year-toggle { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3); border-radius: var(--radius-md); width: 100%; text-align: left; color: var(--text-secondary); font-size: 15px; font-weight: 500; transition: background var(--transition-fast); } -.year-toggle:hover { background: var(--bg-hover); color: var(--text-primary); } -.year-label { font-variant-numeric: tabular-nums; } +.decade-heading { + font-family: var(--font-display); + font-size: 22px; + font-weight: 700; + color: var(--text-primary); + margin-bottom: var(--space-3); +} + +.year-section { + margin-bottom: var(--space-3); +} + +.year-toggle { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3); + border-radius: var(--radius-md); + width: 100%; + text-align: left; + color: var(--text-secondary); + font-size: 15px; + font-weight: 500; + transition: background var(--transition-fast); +} + +.year-toggle:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.year-label { + font-variant-numeric: tabular-nums; +} /* ─ Settings ─ */ -.settings-section { margin-bottom: var(--space-8); } -.settings-section-header { display: flex; align-items: center; gap: var(--space-2); color: var(--accent); margin-bottom: var(--space-3); } -.settings-section-header h2 { font-size: 16px; font-weight: 600; color: var(--text-primary); } -.settings-card { background: var(--bg-card); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); padding: var(--space-5); } +.settings-tabs { + display: flex; + gap: 4px; + padding: 4px; + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + margin-bottom: 2rem; + flex-wrap: wrap; +} + +.settings-tab { + display: flex; + align-items: center; + gap: 7px; + padding: 7px 16px; + border-radius: calc(var(--radius-lg) - 4px); + font-size: 13px; + font-weight: 500; + color: var(--text-muted); + background: none; + border: none; + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast); + white-space: nowrap; +} + +.settings-tab:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.settings-tab.active { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.settings-section { + margin-bottom: var(--space-8); +} + +.settings-section-header { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--accent); + margin-bottom: var(--space-3); +} + +.settings-section-header h2 { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); +} + +.settings-card { + background: var(--bg-card); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + padding: var(--space-5); +} /* ─ Help Page ─ */ -.help-list { display: flex; flex-direction: column; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); overflow: hidden; } -.help-item { border-bottom: 1px solid var(--border-subtle); } -.help-item:last-child { border-bottom: none; } -.help-question { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); width: 100%; padding: var(--space-4) var(--space-5); text-align: left; font-size: 14px; font-weight: 500; color: var(--text-primary); background: var(--bg-card); border-left: 3px solid transparent; transition: background var(--transition-fast), border-color var(--transition-fast); } -.help-question:hover { background: var(--bg-hover); } -.help-item-open .help-question { color: var(--accent); background: var(--bg-hover); border-left: 3px solid var(--accent); padding-left: calc(var(--space-5) + 3px); } -.help-chevron { flex-shrink: 0; color: var(--text-muted); transition: transform 0.2s ease; } -.help-item-open .help-chevron { transform: rotate(180deg); color: var(--accent); } -.help-answer { padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px); font-size: 13px; color: var(--text-secondary); line-height: 1.65; background: var(--bg-app); border-top: 1px solid var(--border-subtle); border-left: 3px solid var(--accent); } -.settings-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-4); } -.settings-about { display: flex; flex-direction: column; } -.settings-about-header { display: flex; align-items: center; gap: var(--space-4); } +.help-list { + display: flex; + flex-direction: column; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.help-item { + border-bottom: 1px solid var(--border-subtle); +} + +.help-item:last-child { + border-bottom: none; +} + +.help-question { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + width: 100%; + padding: var(--space-4) var(--space-5); + text-align: left; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + background: var(--bg-card); + border-left: 3px solid transparent; + transition: background var(--transition-fast), border-color var(--transition-fast); +} + +.help-question:hover { + background: var(--bg-hover); +} + +.help-item-open .help-question { + color: var(--accent); + background: var(--bg-hover); + border-left: 3px solid var(--accent); + padding-left: calc(var(--space-5) + 3px); +} + +.help-chevron { + flex-shrink: 0; + color: var(--text-muted); + transition: transform 0.2s ease; +} + +.help-item-open .help-chevron { + transform: rotate(180deg); + color: var(--accent); +} + +.help-answer { + padding: var(--space-4) var(--space-5) var(--space-5) calc(var(--space-5) + 3px); + font-size: 13px; + color: var(--text-secondary); + line-height: 1.65; + background: var(--bg-app); + border-top: 1px solid var(--border-subtle); + border-left: 3px solid var(--accent); +} + +.settings-toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); +} + +.settings-about { + display: flex; + flex-direction: column; +} + +.settings-about-header { + display: flex; + align-items: center; + gap: var(--space-4); +} /* Toggle switch */ -.toggle-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; } -.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-switch { + position: relative; + display: inline-block; + width: 44px; + height: 24px; + flex-shrink: 0; + cursor: pointer; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + .toggle-track { position: absolute; inset: 0; @@ -866,6 +1505,7 @@ border-radius: var(--radius-full); transition: background var(--transition-base); } + .toggle-track::before { content: ''; position: absolute; @@ -878,8 +1518,14 @@ transition: transform var(--transition-base); box-shadow: var(--shadow-sm); } -.toggle-switch input:checked + .toggle-track { background: var(--accent); } -.toggle-switch input:checked + .toggle-track::before { transform: translateX(20px); } + +.toggle-switch input:checked+.toggle-track { + background: var(--accent); +} + +.toggle-switch input:checked+.toggle-track::before { + transform: translateX(20px); +} /* ─ Login Page ─ */ .login-page { @@ -896,7 +1542,7 @@ position: absolute; inset: 0; background: radial-gradient(ellipse at 20% 50%, rgba(203, 166, 247, 0.08) 0%, transparent 60%), - radial-gradient(ellipse at 80% 20%, rgba(137, 180, 250, 0.06) 0%, transparent 50%); + radial-gradient(ellipse at 80% 20%, rgba(137, 180, 250, 0.06) 0%, transparent 50%); pointer-events: none; } @@ -912,14 +1558,54 @@ z-index: 1; } -.login-logo { display: flex; justify-content: center; margin-bottom: var(--space-5); } -.login-title { text-align: center; font-family: var(--font-display); font-size: 28px; font-weight: 800; background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue)); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; } -.login-subtitle { text-align: center; color: var(--text-muted); font-size: 13px; margin: var(--space-1) 0 var(--space-6); } +.login-logo { + display: flex; + justify-content: center; + margin-bottom: var(--space-5); +} -.login-form { display: flex; flex-direction: column; gap: var(--space-4); } -.form-group { display: flex; flex-direction: column; gap: var(--space-2); } -.form-group label { font-size: 12px; font-weight: 600; color: var(--text-secondary); letter-spacing: 0.03em; } -.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } +.login-title { + text-align: center; + font-family: var(--font-display); + font-size: 28px; + font-weight: 800; + background: linear-gradient(135deg, var(--ctp-mauve), var(--ctp-blue)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.login-subtitle { + text-align: center; + color: var(--text-muted); + font-size: 13px; + margin: var(--space-1) 0 var(--space-6); +} + +.login-form { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.form-group { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.form-group label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + letter-spacing: 0.03em; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-3); +} .login-status { display: flex; @@ -929,19 +1615,77 @@ border-radius: var(--radius-md); font-size: 13px; } -.login-status--testing { background: var(--accent-dim); color: var(--accent); } -.login-status--ok { background: rgba(166, 227, 161, 0.15); color: var(--positive); } -.login-status--error { background: rgba(243, 139, 168, 0.15); color: var(--danger); } -.login-saved-servers { display: flex; flex-direction: column; gap: var(--space-2); margin-bottom: var(--space-4); } -.login-saved-label { font-size: 11px; font-weight: 600; color: var(--text-muted); letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: var(--space-1); } -.login-server-btn { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-3) var(--space-4); text-align: left; width: 100%; } -.login-divider { display: flex; align-items: center; gap: var(--space-3); color: var(--text-muted); font-size: 12px; margin: var(--space-2) 0; } -.login-divider::before, .login-divider::after { content: ''; flex: 1; height: 1px; background: var(--border); } +.login-status--testing { + background: var(--accent-dim); + color: var(--accent); +} + +.login-status--ok { + background: rgba(166, 227, 161, 0.15); + color: var(--positive); +} + +.login-status--error { + background: rgba(243, 139, 168, 0.15); + color: var(--danger); +} + +.login-saved-servers { + display: flex; + flex-direction: column; + gap: var(--space-2); + margin-bottom: var(--space-4); +} + +.login-saved-label { + font-size: 11px; + font-weight: 600; + color: var(--text-muted); + letter-spacing: 0.05em; + text-transform: uppercase; + margin-bottom: var(--space-1); +} + +.login-server-btn { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + text-align: left; + width: 100%; +} + +.login-divider { + display: flex; + align-items: center; + gap: var(--space-3); + color: var(--text-muted); + font-size: 12px; + margin: var(--space-2) 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border); +} /* ─ Loading / Empty ─ */ -.loading-center { display: flex; justify-content: center; align-items: center; height: 200px; } -.empty-state { text-align: center; padding: var(--space-10); color: var(--text-muted); } +.loading-center { + display: flex; + justify-content: center; + align-items: center; + height: 200px; +} + +.empty-state { + text-align: center; + padding: var(--space-10); + color: var(--text-muted); +} /* ───────────────────────────────────────── Fullscreen Player — Ambient Stage @@ -961,41 +1705,101 @@ } @keyframes fsIn { - from { transform: translateY(100%); opacity: 0; } - to { transform: translateY(0); opacity: 1; } + from { + transform: translateY(100%); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } } /* ── Drifting color orbs ── */ @keyframes orb-a { - 0% { transform: translate(0px, 0px) scale(1); } - 33% { transform: translate(90px, -50px) scale(1.12); } - 66% { transform: translate(-40px, 70px) scale(0.94); } - 100% { transform: translate(0px, 0px) scale(1); } + 0% { + transform: translate(0px, 0px) scale(1); + } + + 33% { + transform: translate(90px, -50px) scale(1.12); + } + + 66% { + transform: translate(-40px, 70px) scale(0.94); + } + + 100% { + transform: translate(0px, 0px) scale(1); + } } + @keyframes orb-b { - 0% { transform: translate(0px, 0px) scale(1); } - 33% { transform: translate(-70px, 40px) scale(1.08); } - 66% { transform: translate(50px, -60px) scale(1.14); } - 100% { transform: translate(0px, 0px) scale(1); } + 0% { + transform: translate(0px, 0px) scale(1); + } + + 33% { + transform: translate(-70px, 40px) scale(1.08); + } + + 66% { + transform: translate(50px, -60px) scale(1.14); + } + + 100% { + transform: translate(0px, 0px) scale(1); + } } + @keyframes orb-c { - 0% { transform: translate(0px, 0px) scale(1); } - 50% { transform: translate(60px, 50px) scale(0.9); } - 100% { transform: translate(0px, 0px) scale(1); } + 0% { + transform: translate(0px, 0px) scale(1); + } + + 50% { + transform: translate(60px, 50px) scale(0.9); + } + + 100% { + transform: translate(0px, 0px) scale(1); + } } /* ── Cover breathing ── */ @keyframes cover-breathe { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.018); } + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.018); + } } @keyframes ken-burns { - 0% { transform: scale(1.08) translate(0%, 0%); } - 25% { transform: scale(1.12) translate(-1.5%, 1%); } - 50% { transform: scale(1.10) translate(1%, -1.5%); } - 75% { transform: scale(1.13) translate(1.5%, 0.5%); } - 100% { transform: scale(1.08) translate(0%, 0%); } + 0% { + transform: scale(1.08) translate(0%, 0%); + } + + 25% { + transform: scale(1.12) translate(-1.5%, 1%); + } + + 50% { + transform: scale(1.10) translate(1%, -1.5%); + } + + 75% { + transform: scale(1.13) translate(1.5%, 0.5%); + } + + 100% { + transform: scale(1.08) translate(0%, 0%); + } } /* ── Blurred background ── */ @@ -1003,7 +1807,7 @@ position: absolute; inset: -15%; background-size: cover; - background-position: center; + background-position: top center; filter: blur(6px) brightness(0.25) saturate(1.6); animation: ken-burns 40s ease-in-out infinite; transform: scale(1.2); @@ -1030,23 +1834,32 @@ pointer-events: none; z-index: 0; } + .fs-orb-1 { background: var(--ctp-mauve); - width: 700px; height: 700px; - top: -220px; left: -180px; + width: 700px; + height: 700px; + top: -220px; + left: -180px; animation: orb-a 20s ease-in-out infinite; } + .fs-orb-2 { background: var(--ctp-blue); - width: 600px; height: 600px; - bottom: -180px; right: -120px; + width: 600px; + height: 600px; + bottom: -180px; + right: -120px; animation: orb-b 26s ease-in-out infinite; animation-delay: -9s; } + .fs-orb-3 { background: var(--ctp-lavender); - width: 480px; height: 480px; - top: 35%; right: 5%; + width: 480px; + height: 480px; + top: 35%; + right: 5%; animation: orb-c 17s ease-in-out infinite; animation-delay: -14s; } @@ -1062,13 +1875,14 @@ border-radius: 50%; background: rgba(255, 255, 255, 0.08); backdrop-filter: blur(8px); - color: rgba(255,255,255,0.7); + color: rgba(255, 255, 255, 0.7); display: flex; align-items: center; justify-content: center; transition: all var(--transition-fast); cursor: pointer; } + .fs-close:hover { background: rgba(255, 255, 255, 0.16); color: #ffffff; @@ -1109,12 +1923,14 @@ flex-shrink: 0; animation: cover-breathe 9s ease-in-out infinite; } + .fs-cover { width: 100%; height: 100%; object-fit: cover; display: block; } + .fs-cover-placeholder { width: 100%; height: 100%; @@ -1130,6 +1946,7 @@ text-align: center; width: 100%; } + .fs-title-wrap { font-family: var(--font-display); font-size: clamp(20px, 3vw, 32px); @@ -1140,14 +1957,25 @@ white-space: nowrap; overflow: hidden; } + .fs-title-marquee { display: inline-block; animation: marquee-scroll 14s linear infinite alternate; } + @keyframes marquee-scroll { - 0%, 15% { transform: translateX(0); } - 85%, 100% { transform: translateX(var(--scroll-amount, 0px)); } + + 0%, + 15% { + transform: translateX(0); + } + + 85%, + 100% { + transform: translateX(var(--scroll-amount, 0px)); + } } + .fs-album { font-size: 14px; color: rgba(255, 255, 255, 0.5); @@ -1156,13 +1984,14 @@ overflow: hidden; text-overflow: ellipsis; } + .fs-codec { display: inline-block; font-size: 10px; font-family: monospace; letter-spacing: 0.04em; - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); padding: 2px 8px; border-radius: var(--radius-full); color: rgba(255, 255, 255, 0.35); @@ -1176,32 +2005,34 @@ gap: 12px; width: 100%; } + .fs-time { font-size: 11px; - color: rgba(255,255,255,0.4); + color: rgba(255, 255, 255, 0.4); font-variant-numeric: tabular-nums; min-width: 36px; text-align: center; } + .fs-progress-bar { flex: 1; position: relative; } + .fs-progress-bar input[type="range"] { width: 100%; height: 3px; - background: linear-gradient( - to right, - rgba(255,255,255,0.85) var(--pct, 0%), - rgba(255,255,255,0.32) var(--pct, 0%), - rgba(255,255,255,0.32) var(--buf, 0%), - rgba(255,255,255,0.15) var(--buf, 0%) - ); + background: linear-gradient(to right, + rgba(255, 255, 255, 0.85) var(--pct, 0%), + rgba(255, 255, 255, 0.32) var(--pct, 0%), + rgba(255, 255, 255, 0.32) var(--buf, 0%), + rgba(255, 255, 255, 0.15) var(--buf, 0%)); border-radius: 2px; cursor: pointer; appearance: none; -webkit-appearance: none; } + .fs-progress-bar input[type="range"]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none; @@ -1210,10 +2041,13 @@ border-radius: 50%; background: #ffffff; cursor: pointer; - box-shadow: 0 1px 6px rgba(0,0,0,0.6); + box-shadow: 0 1px 6px rgba(0, 0, 0, 0.6); transition: transform var(--transition-fast); } -.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { transform: scale(1.3); } + +.fs-progress-bar input[type="range"]:hover::-webkit-slider-thumb { + transform: scale(1.3); +} /* Transport controls */ .fs-controls { @@ -1222,6 +2056,7 @@ justify-content: center; gap: 14px; } + .fs-btn { display: flex; align-items: center; @@ -1229,14 +2064,26 @@ width: 36px; height: 36px; border-radius: 50%; - color: rgba(255,255,255,0.6); + color: rgba(255, 255, 255, 0.6); cursor: pointer; transition: all var(--transition-fast); background: transparent; } -.fs-btn:hover { color: #ffffff; background: rgba(255,255,255,0.1); } -.fs-btn.active { color: var(--ctp-lavender); } -.fs-btn-sm { width: 28px; height: 28px; } + +.fs-btn:hover { + color: #ffffff; + background: rgba(255, 255, 255, 0.1); +} + +.fs-btn.active { + color: var(--ctp-lavender); +} + +.fs-btn-sm { + width: 28px; + height: 28px; +} + .fs-btn-play { width: 54px; height: 54px; @@ -1244,11 +2091,12 @@ color: var(--ctp-crust); box-shadow: 0 8px 28px rgba(0, 0, 0, 0.5); } + .fs-btn-play:hover { background: linear-gradient(135deg, var(--ctp-lavender), var(--ctp-mauve)); color: var(--ctp-crust); transform: scale(1.07); - box-shadow: 0 10px 34px rgba(0,0,0,0.6); + box-shadow: 0 10px 34px rgba(0, 0, 0, 0.6); } /* Chat */ @@ -1260,7 +2108,7 @@ backdrop-filter: blur(12px); border: 1px solid var(--border); border-radius: 12px; - box-shadow: 0 8px 32px rgba(0,0,0,0.6); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); display: flex; flex-direction: column; z-index: 9999; @@ -1269,8 +2117,15 @@ } @keyframes slideInDown { - from { opacity: 0; transform: translateY(-10px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } } .chat-popup-header { @@ -1491,7 +2346,10 @@ gap: 1rem; margin-bottom: 1.5rem; } -.playlist-page-header .page-title { margin-bottom: 0; } + +.playlist-page-header .page-title { + margin-bottom: 0; +} .playlist-filter-input { background: var(--bg-surface); @@ -1504,10 +2362,19 @@ outline: none; transition: border-color 0.15s; } -.playlist-filter-input:focus { border-color: var(--accent); } -.playlist-filter-input::placeholder { color: var(--text-muted); } -.playlist-list { display: flex; flex-direction: column; } +.playlist-filter-input:focus { + border-color: var(--accent); +} + +.playlist-filter-input::placeholder { + color: var(--text-muted); +} + +.playlist-list { + display: flex; + flex-direction: column; +} .playlist-list-header { display: grid; @@ -1533,8 +2400,11 @@ padding: 0; transition: color 0.15s; } + .playlist-sort-btn:hover, -.playlist-sort-btn.active { color: var(--accent); } +.playlist-sort-btn.active { + color: var(--accent); +} .playlist-row { display: grid; @@ -1545,7 +2415,10 @@ border-radius: var(--radius-md); transition: background 0.15s; } -.playlist-row:hover { background: var(--bg-hover); } + +.playlist-row:hover { + background: var(--bg-hover); +} .playlist-play-icon { display: flex; @@ -1562,8 +2435,14 @@ transition: opacity 0.15s, transform 0.15s; flex-shrink: 0; } -.playlist-row:hover .playlist-play-icon { opacity: 1; } -.playlist-play-icon:hover { transform: scale(1.1); } + +.playlist-row:hover .playlist-play-icon { + opacity: 1; +} + +.playlist-play-icon:hover { + transform: scale(1.1); +} .playlist-name { font-size: 14px; @@ -1586,7 +2465,10 @@ transition: opacity 0.15s; justify-self: center; } -.playlist-row:hover .playlist-delete-btn { opacity: 1; } + +.playlist-row:hover .playlist-delete-btn { + opacity: 1; +} /* ─ Statistics Page ─ */ .stats-page { @@ -1600,8 +2482,11 @@ grid-template-columns: repeat(4, 1fr); gap: var(--space-4); } + @media (max-width: 600px) { - .stats-overview { grid-template-columns: repeat(2, 1fr); } + .stats-overview { + grid-template-columns: repeat(2, 1fr); + } } .stats-card { @@ -1615,6 +2500,7 @@ align-items: center; text-align: center; } + .stats-card-value { font-family: var(--font-display); font-size: 2rem; @@ -1622,6 +2508,7 @@ color: var(--accent); line-height: 1; } + .stats-card-label { font-size: 12px; font-weight: 600; @@ -1640,13 +2527,20 @@ flex-direction: column; gap: var(--space-4); } -.genre-row { display: flex; flex-direction: column; gap: 6px; } + +.genre-row { + display: flex; + flex-direction: column; + gap: 6px; +} + .genre-row-header { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-3); } + .genre-name { font-size: 13px; font-weight: 500; @@ -1656,12 +2550,14 @@ text-overflow: ellipsis; white-space: nowrap; } + .genre-counts { font-size: 12px; color: var(--text-muted); white-space: nowrap; flex-shrink: 0; } + .genre-bar-track { width: 100%; height: 6px; @@ -1669,6 +2565,7 @@ border-radius: 3px; overflow: hidden; } + .genre-bar-fill { height: 100%; background: var(--accent); @@ -1709,8 +2606,15 @@ } @keyframes led-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.3; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.3; + } } .connection-meta { @@ -1792,8 +2696,13 @@ } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } /* ─ Now Playing Page ─ */ @@ -1807,7 +2716,7 @@ /* Hero */ .np-hero { position: relative; - min-height: 420px; + min-height: 280px; display: flex; align-items: center; flex-shrink: 0; @@ -1815,10 +2724,12 @@ .np-bg-wrap { position: absolute; - inset: 0 0 -120px 0; + inset: 0 0 -80px 0; z-index: 0; overflow: hidden; + contain: paint; } + .np-bg-layer { position: absolute; inset: -15%; @@ -1826,41 +2737,52 @@ background-position: center; filter: blur(50px) brightness(0.25) saturate(1.8); transition: opacity 0.6s ease; - animation: ken-burns 40s ease-in-out infinite; - transform-origin: center center; + will-change: opacity; } + .np-bg-overlay { position: absolute; inset: 0; background: linear-gradient(to bottom, transparent 40%, var(--bg-app) 100%); } -/* Orbs */ +/* Orbs — will-change promotes each to its own compositor layer so + the blur is composited on the GPU and doesn't repaint on every frame */ .np-orb { position: absolute; border-radius: 50%; - filter: blur(90px); - opacity: 0.35; + filter: blur(70px); + opacity: 0.3; pointer-events: none; z-index: 0; + will-change: transform; } + .np-orb-1 { background: var(--ctp-mauve); - width: 560px; height: 560px; - top: -180px; left: -120px; + width: 400px; + height: 400px; + top: -120px; + left: -80px; animation: orb-a 18s ease-in-out infinite; } + .np-orb-2 { background: var(--ctp-blue); - width: 460px; height: 460px; - bottom: -120px; right: -100px; + width: 320px; + height: 320px; + bottom: -80px; + right: -60px; animation: orb-b 24s ease-in-out infinite; animation-delay: -9s; } + .np-orb-3 { background: var(--ctp-lavender); - width: 380px; height: 380px; - top: 30%; right: 10%; + width: 260px; + height: 260px; + top: 30%; + right: 10%; animation: orb-c 15s ease-in-out infinite; animation-delay: -5s; } @@ -1872,8 +2794,8 @@ flex-direction: column; align-items: center; text-align: center; - gap: 24px; - padding: 48px 56px 52px; + gap: 16px; + padding: 28px 56px 32px; width: 100%; box-sizing: border-box; } @@ -1886,34 +2808,38 @@ align-items: center; justify-content: center; } + .np-cover-glow { position: absolute; - width: 220px; - height: 220px; + width: 170px; + height: 170px; border-radius: 50%; object-fit: cover; - filter: blur(40px) saturate(1.6) brightness(0.9); - opacity: 0.75; - transform: translateY(16px) scale(1.05); + filter: blur(36px) saturate(1.5) brightness(0.85); + opacity: 0.7; + transform: translateY(12px) scale(1.05); pointer-events: none; z-index: 0; + will-change: transform, opacity; animation: np-glow-pulse 4s ease-in-out infinite; } @keyframes np-glow-pulse { - 0%, 100% { opacity: 0.65; transform: translateY(18px) scale(1.05); filter: blur(38px) saturate(1.5) brightness(0.85); } - 50% { opacity: 0.9; transform: translateY(12px) scale(1.12); filter: blur(50px) saturate(2.0) brightness(1.0); } + 0%, 100% { opacity: 0.6; transform: translateY(14px) scale(1.04); } + 50% { opacity: 0.85; transform: translateY(9px) scale(1.10); } } + .np-cover { position: relative; z-index: 1; - width: 210px; - height: 210px; + width: 160px; + height: 160px; border-radius: 14px; object-fit: cover; - box-shadow: 0 24px 70px rgba(0,0,0,0.6); + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.6); animation: cover-breathe 6s ease-in-out infinite; } + .np-cover-fallback { display: flex; align-items: center; @@ -1942,15 +2868,22 @@ .np-artist-album { font-size: 15px; - color: rgba(255,255,255,0.75); + color: rgba(255, 255, 255, 0.75); display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; align-items: center; } -.np-link:hover { color: white; text-decoration: underline; } -.np-sep { opacity: 0.4; } + +.np-link:hover { + color: white; + text-decoration: underline; +} + +.np-sep { + opacity: 0.4; +} .np-tech-row { display: flex; @@ -1960,15 +2893,17 @@ align-items: center; margin-top: 4px; } + .np-badge { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 999px; - background: rgba(255,255,255,0.12); - color: rgba(255,255,255,0.85); + background: rgba(255, 255, 255, 0.12); + color: rgba(255, 255, 255, 0.85); letter-spacing: 0.04em; } + .np-star-btn { background: none; border: none; @@ -1979,7 +2914,10 @@ color: white; transition: transform 0.15s; } -.np-star-btn:hover { transform: scale(1.2); } + +.np-star-btn:hover { + transform: scale(1.2); +} /* Progress */ .np-progress-wrap { @@ -1988,10 +2926,14 @@ gap: 10px; margin-top: 4px; } -.np-waveform { flex: 1; } + +.np-waveform { + flex: 1; +} + .np-time { font-size: 12px; - color: rgba(255,255,255,0.6); + color: rgba(255, 255, 255, 0.6); font-variant-numeric: tabular-nums; flex-shrink: 0; min-width: 36px; @@ -2004,21 +2946,37 @@ gap: 16px; margin-top: 4px; } + .np-ctrl-btn { background: none; border: none; cursor: pointer; - color: rgba(255,255,255,0.75); + color: rgba(255, 255, 255, 0.75); display: flex; align-items: center; padding: 6px; border-radius: 50%; transition: color 0.15s, background 0.15s; } -.np-ctrl-btn:hover { color: white; background: rgba(255,255,255,0.1); } -.np-ctrl-btn:disabled { opacity: 0.3; cursor: default; } -.np-ctrl-btn.np-ctrl-active { color: var(--accent); } -.np-ctrl-lg { color: rgba(255,255,255,0.9); } + +.np-ctrl-btn:hover { + color: white; + background: rgba(255, 255, 255, 0.1); +} + +.np-ctrl-btn:disabled { + opacity: 0.3; + cursor: default; +} + +.np-ctrl-btn.np-ctrl-active { + color: var(--accent); +} + +.np-ctrl-lg { + color: rgba(255, 255, 255, 0.9); +} + .np-ctrl-play { width: 60px; height: 60px; @@ -2031,9 +2989,13 @@ align-items: center; justify-content: center; transition: transform 0.15s, box-shadow 0.15s; - box-shadow: 0 4px 20px rgba(0,0,0,0.3); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); +} + +.np-ctrl-play:hover { + transform: scale(1.07); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4); } -.np-ctrl-play:hover { transform: scale(1.07); box-shadow: 0 8px 30px rgba(0,0,0,0.4); } /* Volume */ .np-volume-row { @@ -2041,20 +3003,24 @@ align-items: center; gap: 8px; } + .np-volume-slider { width: 120px; accent-color: white; opacity: 0.7; cursor: pointer; } -.np-volume-slider:hover { opacity: 1; } + +.np-volume-slider:hover { + opacity: 1; +} .np-empty-state { display: flex; flex-direction: column; align-items: center; gap: 12px; - color: rgba(255,255,255,0.5); + color: rgba(255, 255, 255, 0.5); padding: 40px 0; } @@ -2075,22 +3041,26 @@ border-bottom: 1px solid var(--border-subtle); margin-bottom: 4px; } + .np-queue-title { font-size: 14px; font-weight: 600; color: var(--text-primary); margin: 0; } + .np-queue-meta { font-size: 11px; color: var(--text-muted); margin-top: 2px; } + .np-queue-actions { display: flex; align-items: center; gap: 4px; } + .np-action-btn { background: none; border: none; @@ -2102,8 +3072,16 @@ align-items: center; transition: color 0.15s, background 0.15s; } -.np-action-btn:hover { color: var(--text-primary); background: var(--bg-hover); } -.np-action-btn:disabled { opacity: 0.3; cursor: default; } + +.np-action-btn:hover { + color: var(--text-primary); + background: var(--bg-hover); +} + +.np-action-btn:disabled { + opacity: 0.3; + cursor: default; +} /* Queue list */ .np-queue-list { @@ -2121,10 +3099,12 @@ cursor: pointer; transition: background 0.12s; } + .np-queue-item:hover, .np-queue-item.context-active { background: var(--bg-hover); } + .np-queue-item.active { background: color-mix(in srgb, var(--accent) 10%, transparent); } @@ -2138,28 +3118,36 @@ font-variant-numeric: tabular-nums; min-width: 24px; } + .np-queue-item-info { min-width: 0; display: flex; flex-direction: column; gap: 2px; } + .np-queue-item-title { font-size: 13px; font-weight: 500; color: var(--text-primary); } -.np-queue-item-active { color: var(--accent); } + +.np-queue-item-active { + color: var(--accent); +} + .np-queue-item-artist { font-size: 11px; color: var(--text-muted); } + .np-queue-item-duration { font-size: 12px; color: var(--text-muted); font-variant-numeric: tabular-nums; flex-shrink: 0; } + .np-queue-remove { background: none; border: none; @@ -2174,8 +3162,15 @@ opacity: 0; transition: opacity 0.12s, color 0.12s, background 0.12s; } -.np-queue-item:hover .np-queue-remove { opacity: 1; } -.np-queue-remove:hover { color: var(--ctp-red); background: var(--bg-hover); } + +.np-queue-item:hover .np-queue-remove { + opacity: 1; +} + +.np-queue-remove:hover { + color: var(--ctp-red); + background: var(--bg-hover); +} /* ─ Equalizer ─ */ .eq-wrap { @@ -2227,7 +3222,11 @@ transition: background 0.15s, color 0.15s; flex-shrink: 0; } -.eq-ctrl-btn:hover { background: var(--accent); color: var(--bg-app); } + +.eq-ctrl-btn:hover { + background: var(--accent); + color: var(--bg-app); +} .eq-save-row { display: flex; @@ -2235,14 +3234,15 @@ gap: 8px; } -/* Main EQ panel — dark canvas area + faders */ +/* Main EQ panel — canvas area + faders */ .eq-panel { - background: #0d0d12; - border: 1px solid rgba(255,255,255,0.07); + background: var(--bg-app); + border: 1px solid var(--border); border-radius: 10px; overflow: hidden; transition: opacity 0.2s; } + .eq-panel--off { opacity: 0.4; pointer-events: none; @@ -2252,14 +3252,16 @@ display: block; width: 100%; height: 120px; + margin-bottom: 12px; } .eq-faders { display: flex; align-items: stretch; - padding: 6px 8px 12px; + padding: 12px 8px 12px; gap: 0; height: 155px; + border-top: 1px solid var(--border); } /* dB scale on left */ @@ -2271,9 +3273,10 @@ width: 28px; flex-shrink: 0; } + .eq-db-tick { font-size: 9px; - color: rgba(255,255,255,0.3); + color: var(--text-muted); font-family: monospace; text-align: right; line-height: 1; @@ -2291,7 +3294,7 @@ .eq-gain-val { font-size: 9px; - color: rgba(255,255,255,0.45); + color: var(--text-muted); font-family: monospace; font-variant-numeric: tabular-nums; line-height: 1; @@ -2313,55 +3316,155 @@ left: 10%; right: 10%; height: 1px; - background: rgba(255,255,255,0.35); + background: var(--text-muted); pointer-events: none; top: 50%; transform: translateY(-50%); } -/* Vertical fader via rotate(-90deg) + position:absolute. - Track is vertically centred in the element by WebKit; thumb defaults to element - centre too — so translateX(-50%) + no margin-top keeps everything aligned. */ -.eq-fader { +/* Custom div-based vertical fader — no native range input, full pixel control */ +.eq-fader-custom { + position: absolute; + inset: 0; + user-select: none; + touch-action: none; +} + +/* The thin vertical track line */ +.eq-track-line { position: absolute; left: 50%; - top: 50%; - transform: translateX(-50%) translateY(-50%) rotate(-90deg); - -webkit-appearance: none; - appearance: none; - width: 100px; - height: 20px; - cursor: pointer; - background: transparent; - margin: 0; - padding: 0; - border: none; - outline: none; -} -.eq-fader::-webkit-slider-runnable-track { - height: 3px; - background: rgba(255,255,255,0.3); + top: 0; + bottom: 0; + width: 3px; + transform: translateX(-50%); + background: var(--border); border-radius: 2px; + pointer-events: none; } -.eq-fader::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 8px; - height: 20px; + +/* The draggable handle */ +.eq-thumb { + position: absolute; + left: 50%; + width: 20px; + height: 8px; + transform: translateX(-50%) translateY(-50%); background: var(--accent); border-radius: 3px; - box-shadow: 0 2px 8px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.18); - cursor: grab; - transition: background 0.12s; -} -.eq-fader::-webkit-slider-thumb:active { cursor: grabbing; } -.eq-fader:disabled::-webkit-slider-thumb { - background: rgba(255,255,255,0.15); - box-shadow: none; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.18); + pointer-events: none; + transition: opacity 0.15s; } .eq-freq-label { font-size: 9px; - color: rgba(255,255,255,0.35); + color: var(--text-muted); white-space: nowrap; } +/* ─── Changelog ────────────────────────────────────────────────────────────── */ + +.changelog-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.changelog-entry { + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card); + overflow: hidden; +} + +.changelog-summary { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + cursor: pointer; + list-style: none; + user-select: none; + color: var(--text-primary); +} + +.changelog-summary::-webkit-details-marker { display: none; } + +.changelog-summary::before { + content: '▶'; + font-size: 9px; + color: var(--text-muted); + transition: transform 0.15s; + flex-shrink: 0; +} + +.changelog-entry[open] > .changelog-summary::before { + transform: rotate(90deg); +} + +.changelog-version { + font-size: 13px; + font-weight: 600; + color: var(--accent); + font-variant-numeric: tabular-nums; +} + +.changelog-date { + font-size: 11px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.changelog-body { + padding: 4px 14px 12px 28px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.changelog-h3 { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--accent); + margin-top: 10px; + margin-bottom: 2px; +} + +.changelog-h4 { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin-top: 6px; +} + +.changelog-item { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.5; + padding-left: 12px; + position: relative; +} + +.changelog-item::before { + content: '·'; + position: absolute; + left: 2px; + color: var(--text-muted); +} + +.changelog-text { + font-size: 12px; + color: var(--text-muted); + line-height: 1.5; +} + +.changelog-code { + font-family: var(--font-mono, monospace); + font-size: 11px; + background: var(--bg-hover); + color: var(--accent); + padding: 1px 4px; + border-radius: 3px; +} diff --git a/src/styles/layout.css b/src/styles/layout.css index 833fab47..d107b19c 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -489,6 +489,74 @@ min-width: 0; } +.player-eq-btn { + color: var(--text-muted); + flex-shrink: 0; + transition: color 0.15s; +} +.player-eq-btn:hover, +.player-eq-btn.active { + color: var(--accent); +} + +/* ─── EQ Popup ─── */ +.eq-popup-backdrop { + position: fixed; + inset: 0; + z-index: 200; + background: rgba(0, 0, 0, 0.35); + backdrop-filter: blur(2px); +} + +.eq-popup { + position: fixed; + z-index: 201; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(700px, 92vw); + background: var(--bg-sidebar); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); + padding: 20px; + display: flex; + flex-direction: column; + gap: 16px; +} +.eq-popup .eq-wrap { + max-width: none; +} + +.eq-popup-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.eq-popup-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); +} + +.eq-popup-close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 6px; + background: var(--bg-hover); + border: 1px solid var(--border); + color: var(--text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.eq-popup-close:hover { + background: var(--accent); + color: var(--bg-app); +} /* ─── Queue Panel ─── */ .queue-panel { diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +///