diff --git a/README.md b/README.md index d285f28c..f65bdd78 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,10 @@ If you want to build Psysonic from source or contribute to the project: ### Prerequisites - [Node.js](https://nodejs.org/) (v18+) - [Rust](https://www.rust-lang.org/) (v1.75+) +- **`cmake`** — required to compile the bundled libopus (Opus audio support). Install it before running `cargo build` or `npm run tauri:build`: + - Linux: `sudo apt install cmake` / `sudo pacman -S cmake` + - macOS: `brew install cmake` + - Windows: [cmake.org/download](https://cmake.org/download/) or `winget install cmake` - OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)). ### Setup diff --git a/package-lock.json b/package-lock.json index b5c8c9fb..53a92c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "psysonic", - "version": "1.34.9", + "version": "1.34.11-dev", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "psysonic", - "version": "1.34.9", + "version": "1.34.11-dev", "dependencies": { "@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/figtree": "^5.2.10", @@ -2278,9 +2278,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bb1be099..e7194d7e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -619,6 +619,15 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.24.1" @@ -3078,6 +3087,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "opusic-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3280fe5b6f97ac1a35a0ac003e2fb0b92f8e4bdf2b2057e1bf9b87acca5696" +dependencies = [ + "cmake", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3581,6 +3599,8 @@ dependencies = [ "serde_json", "souvlaki", "symphonia", + "symphonia-adapter-libopus", + "sysinfo", "tauri", "tauri-build", "tauri-plugin-dialog", @@ -4739,6 +4759,17 @@ dependencies = [ "symphonia-metadata", ] +[[package]] +name = "symphonia-adapter-libopus" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d17450685dda0e87467eddf3e0f9c0b2a1707fc5c3234c111f70d46c6e4494" +dependencies = [ + "log", + "opusic-sys", + "symphonia-core", +] + [[package]] name = "symphonia-bundle-flac" version = "0.5.5" @@ -4927,6 +4958,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "sysinfo" +version = "0.33.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01" +dependencies = [ + "core-foundation-sys", + "libc", + "windows 0.54.0", +] + [[package]] name = "system-deps" version = "6.2.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c0f0787f..3a19dd16 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -7,7 +7,7 @@ license = "" repository = "" default-run = "psysonic" edition = "2021" -rust-version = "1.77.2" +rust-version = "1.89" [lib] name = "psysonic_lib" @@ -45,7 +45,9 @@ discord-rich-presence = "0.2" url = "2" thread-priority = "1" lofty = "0.22" +sysinfo = { version = "0.33", default-features = false, features = ["disk"] } id3 = "1.16.4" +symphonia-adapter-libopus = "0.2.7" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index c26c61f1..e049ebe6 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,5 +1,5 @@ use std::io::{Cursor, Read, Seek, SeekFrom}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering}; use std::time::{Duration, Instant}; #[cfg(unix)] @@ -13,7 +13,7 @@ use rodio::source::UniformSourceIterator; use serde::Serialize; use symphonia::core::{ audio::{AudioBufferRef, SampleBuffer, SignalSpec}, - codecs::{DecoderOptions, CODEC_TYPE_NULL}, + codecs::{CodecRegistry, DecoderOptions, CODEC_TYPE_NULL}, formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, meta::MetadataOptions, @@ -682,11 +682,22 @@ impl Drop for RadioLiveState { // [features] // fdk-aac = ["dep:symphonia-adapter-fdk-aac"] +/// Symphonia’s default codec set for our enabled features, plus Opus via libopus. +fn psysonic_codec_registry() -> &'static CodecRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + let mut registry = CodecRegistry::new(); + symphonia::default::register_enabled_codecs(&mut registry); + registry.register_all::(); + registry + }) +} + fn try_make_radio_decoder( params: &symphonia::core::codecs::CodecParameters, opts: &DecoderOptions, ) -> Result, symphonia::core::errors::Error> { - symphonia::default::get_codecs().make(params, opts) + psysonic_codec_registry().make(params, opts) } // ── Async HTTP Download Task ────────────────────────────────────────────────── @@ -980,7 +991,7 @@ impl SizedDecoder { .zip(track.codec_params.n_frames) .map(|(base, frames)| base.calc_time(frames)); - let mut decoder = symphonia::default::get_codecs() + let mut decoder = psysonic_codec_registry() .make(&track.codec_params, &DecoderOptions::default()) .map_err(|e| { eprintln!("[psysonic] codec init failed: {e}"); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a0bd57cd..a7c882e3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1351,7 +1351,68 @@ async fn purge_hot_cache(custom_dir: Option, app: tauri::AppHandle) -> R // ─── Device Sync ───────────────────────────────────────────────────────────── -#[derive(serde::Deserialize)] +/// Information about a single mounted removable drive. +#[derive(Clone, serde::Serialize)] +struct RemovableDrive { + name: String, + mount_point: String, + available_space: u64, + total_space: u64, + file_system: String, + is_removable: bool, +} + +/// Returns all currently mounted removable drives. +/// On Linux these are typically USB sticks / SD cards under /media or /run/media. +/// On macOS they appear under /Volumes. On Windows they are separate drive letters. +#[tauri::command] +fn get_removable_drives() -> Vec { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + disks + .list() + .iter() + .filter(|d| d.is_removable()) + .map(|d| RemovableDrive { + name: d.name().to_string_lossy().to_string(), + mount_point: d.mount_point().to_string_lossy().to_string(), + available_space: d.available_space(), + total_space: d.total_space(), + file_system: d.file_system().to_string_lossy().to_string(), + is_removable: true, + }) + .collect() +} + +/// Checks whether `path` sits on top of an active mount point (i.e. not the root +/// filesystem). This prevents accidentally writing to `/media/usb` after the +/// USB drive has been unmounted — at that point the path would fall through to `/` +/// and fill the root partition. +fn is_path_on_mounted_volume(path: &std::path::Path) -> bool { + use sysinfo::Disks; + let disks = Disks::new_with_refreshed_list(); + let canonical = match path.canonicalize() { + Ok(c) => c, + Err(_) => return false, // path doesn't exist or isn't accessible + }; + let canonical_str = canonical.to_string_lossy(); + // Find the longest mount-point prefix that matches this path. + // Exclude the root "/" (or "C:\" on Windows) so we never "match" a fallback. + let mut best_len: usize = 0; + for disk in disks.list() { + let mp = disk.mount_point().to_string_lossy().to_string(); + // Skip root mount points + if mp == "/" || (mp.len() == 3 && mp.ends_with(":\\")) { + continue; + } + if canonical_str.starts_with(&mp) && mp.len() > best_len { + best_len = mp.len(); + } + } + best_len > 0 +} + +#[derive(serde::Deserialize, Clone)] struct TrackSyncInfo { id: String, url: String, @@ -1366,6 +1427,14 @@ struct TrackSyncInfo { year: Option, } +/// Summary returned by `sync_batch_to_device` after all tracks are processed. +#[derive(Clone, serde::Serialize)] +struct SyncBatchResult { + done: u32, + skipped: u32, + failed: u32, +} + #[derive(serde::Serialize)] struct SyncTrackResult { path: String, @@ -1524,22 +1593,445 @@ async fn delete_device_file(path: String) -> Result<(), String> { let p = std::path::PathBuf::from(&path); if p.exists() { tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?; - // Prune empty parent dirs (album → artist) - let mut current = p.parent().map(|d| d.to_path_buf()); - for _ in 0..2 { - let Some(dir) = current else { break }; - let is_empty = std::fs::read_dir(&dir) - .map(|mut rd| rd.next().is_none()) - .unwrap_or(false); - if is_empty { - let _ = tokio::fs::remove_dir(&dir).await; - current = dir.parent().map(|d| d.to_path_buf()); - } else { - break; + prune_empty_parents(&p, 2).await; + } + Ok(()) +} + +/// Prune empty parent directories up to `levels` levels above `file_path`. +async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) { + let mut current = file_path.parent().map(|d| d.to_path_buf()); + for _ in 0..levels { + let Some(dir) = current else { break }; + let is_empty = std::fs::read_dir(&dir) + .map(|mut rd| rd.next().is_none()) + .unwrap_or(false); + if is_empty { + let _ = tokio::fs::remove_dir(&dir).await; + current = dir.parent().map(|d| d.to_path_buf()); + } else { + break; + } + } +} + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct SubsonicAuthPayload { + base_url: String, + u: String, + t: String, + s: String, + v: String, + c: String, + f: String, +} + +#[derive(serde::Deserialize)] +struct DeviceSyncSourcePayload { + #[serde(rename = "type")] + source_type: String, + id: String, +} + +#[derive(serde::Serialize)] +#[serde(rename_all = "camelCase")] +struct SyncDeltaResult { + add_bytes: u64, + add_count: u32, + del_bytes: u64, + del_count: u32, + available_bytes: u64, + tracks: Vec, +} + +async fn fetch_subsonic_songs( + client: &reqwest::Client, + auth: &SubsonicAuthPayload, + endpoint: &str, + id: &str, +) -> Result, String> { + let url = format!("{}/{}", auth.base_url, endpoint); + let query = vec![ + ("u", auth.u.as_str()), + ("t", auth.t.as_str()), + ("s", auth.s.as_str()), + ("v", auth.v.as_str()), + ("c", auth.c.as_str()), + ("f", auth.f.as_str()), + ("id", id), + ]; + let res = client.get(&url).query(&query).send().await.map_err(|e| e.to_string())?; + let json: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + + let root = json.get("subsonic-response").ok_or("No subsonic-response".to_string())?; + let songs = if endpoint == "getAlbum.view" { + root.get("album").and_then(|a| a.get("song")) + } else if endpoint == "getPlaylist.view" { + root.get("playlist").and_then(|p| p.get("entry")) + } else { + None + }; + + if let Some(arr) = songs.and_then(|s| s.as_array()) { + return Ok(arr.clone()); + } else if let Some(obj) = songs.and_then(|s| s.as_object()) { + return Ok(vec![serde_json::Value::Object(obj.clone())]); + } + Ok(vec![]) +} + +#[tauri::command] +async fn calculate_sync_payload( + sources: Vec, + deletion_ids: Vec, + auth: SubsonicAuthPayload, + target_dir: String, + template: String, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| e.to_string())?; + + let mut add_bytes = 0; + let mut add_count = 0; + let mut del_bytes = 0; + let mut del_count = 0; + + let mut sync_tracks = Vec::new(); + let (mut del_sources, mut add_sources) = (Vec::new(), Vec::new()); + for s in sources { + if deletion_ids.contains(&s.id) { + del_sources.push(s); + } else { + add_sources.push(s); + } + } + + let mut handles = Vec::new(); + for source in add_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + handles.push(tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "artist" { + let url = format!("{}/getArtist.view", auth_clone.base_url); + let query = vec![("u", auth_clone.u.as_str()), ("t", auth_clone.t.as_str()), ("s", auth_clone.s.as_str()), ("v", auth_clone.v.as_str()), ("c", auth_clone.c.as_str()), ("f", auth_clone.f.as_str()), ("id", &source.id)]; + if let Ok(re) = cli.get(&url).query(&query).send().await { + if let Ok(js) = re.json::().await { + if let Some(root) = js.get("subsonic-response").and_then(|r| r.get("artist")).and_then(|a| a.get("album")) { + let arr = root.as_array().map(|a| a.clone()).unwrap_or_else(|| { + root.as_object().map(|o| vec![serde_json::Value::Object(o.clone())]).unwrap_or_else(|| vec![]) + }); + for al in arr { + if let Some(aid) = al.get("id").and_then(|i| i.as_str()) { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", aid).await { + res_tracks.extend(ts); + } + } + } + } + } + } + } + res_tracks + })); + } + + let mut del_handles = Vec::new(); + for source in del_sources { + let auth_clone = SubsonicAuthPayload { + base_url: auth.base_url.clone(), u: auth.u.clone(), t: auth.t.clone(), s: auth.s.clone(), + v: auth.v.clone(), c: auth.c.clone(), f: auth.f.clone(), + }; + let cli = client.clone(); + del_handles.push(tokio::spawn(async move { + let mut res_tracks = Vec::new(); + if source.source_type == "album" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getAlbum.view", &source.id).await { res_tracks.extend(ts); } + } else if source.source_type == "playlist" { + if let Ok(ts) = fetch_subsonic_songs(&cli, &auth_clone, "getPlaylist.view", &source.id).await { res_tracks.extend(ts); } + } + res_tracks + })); + } + + let mut seen = std::collections::HashSet::new(); + for handle in handles { + if let Ok(ts) = handle.await { + for track in ts { + if let Some(tid) = track.get("id").and_then(|i| i.as_str()) { + if !seen.contains(tid) { + seen.insert(tid.to_string()); + // Build the expected path and skip files already present on device. + let already_exists = { + let suffix = track.get("suffix").and_then(|s| s.as_str()).unwrap_or("mp3"); + let sync_info = TrackSyncInfo { + id: tid.to_string(), + url: String::new(), + suffix: suffix.to_string(), + artist: track.get("artist").and_then(|v| v.as_str()).unwrap_or("").to_string(), + album: track.get("album").and_then(|v| v.as_str()).unwrap_or("").to_string(), + title: track.get("title").and_then(|v| v.as_str()).unwrap_or("").to_string(), + track_number: track.get("track").and_then(|v| v.as_u64()).map(|n| n as u32), + disc_number: track.get("discNumber").and_then(|v| v.as_u64()).map(|n| n as u32), + year: track.get("year").and_then(|v| v.as_u64()).map(|n| n as u32), + }; + let relative = apply_device_sync_template(&template, &sync_info); + let file_name = format!("{}.{}", relative, suffix); + std::path::Path::new(&target_dir).join(&file_name).exists() + }; + if !already_exists { + add_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + add_bytes += size; + sync_tracks.push(track); + } + } + } } } } - Ok(()) + + for handle in del_handles { + if let Ok(ts) = handle.await { + for track in ts { + del_count += 1; + let size = track.get("size").and_then(|s| s.as_u64()).unwrap_or_else(|| { + track.get("duration").and_then(|d| d.as_u64()).unwrap_or(0) * 320_000 / 8 + }); + del_bytes += size; + } + } + } + + let mut available_bytes = 0; + for drive in get_removable_drives() { + if target_dir.starts_with(&drive.mount_point) { + available_bytes = drive.available_space; + break; + } + } + + Ok(SyncDeltaResult { + add_bytes, add_count, del_bytes, del_count, available_bytes, tracks: sync_tracks, + }) +} + +/// Downloads a batch of tracks to a USB/SD device with controlled concurrency. +/// At most 2 parallel writes run simultaneously to prevent I/O choking on USB. +/// Emits throttled `device:sync:progress` events (max once per 500ms) and a +/// final `device:sync:complete` event with the summary. +#[tauri::command] +async fn sync_batch_to_device( + tracks: Vec, + dest_dir: String, + template: String, + job_id: String, + expected_bytes: u64, + app: tauri::AppHandle, +) -> Result { + use std::sync::atomic::{AtomicU32, Ordering}; + use std::time::{Duration, Instant}; + use tokio::sync::Mutex; + + let dest_root = std::path::PathBuf::from(&dest_dir); + if !dest_root.exists() { + return Err("VOLUME_NOT_FOUND".to_string()); + } + // Safety: verify dest_dir is on an actual mounted volume, not the root FS. + // This catches the case where a USB drive was unmounted but the empty + // mount-point directory still exists — writing there fills the root partition. + if !is_path_on_mounted_volume(&dest_root) { + return Err("NOT_MOUNTED_VOLUME".to_string()); + } + + // Safety: Ensure target logic hasn't exceeded physical volume capacities securely stopping dead bytes natively. + let drives = get_removable_drives(); + let dest_canon = dest_root.canonicalize().unwrap_or_else(|_| dest_root.clone()); + let dest_str = dest_canon.to_string_lossy(); + + for drive in drives { + if dest_str.starts_with(&drive.mount_point) { + // Buffer of ~10 MB padding boundary natively mapped + if expected_bytes > drive.available_space.saturating_sub(10_000_000) { + return Err(format!("NOT_ENOUGH_SPACE")); + } + break; + } + } + + // Shared reqwest client — reused across all downloads. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| e.to_string())?; + + // Concurrency limiter: max 2 parallel USB writes. + let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new(2)); + + // Counters. + let done = std::sync::Arc::new(AtomicU32::new(0)); + let skipped = std::sync::Arc::new(AtomicU32::new(0)); + let failed = std::sync::Arc::new(AtomicU32::new(0)); + + // Throttled event emission (max once per 500ms). + let last_emit = std::sync::Arc::new(Mutex::new(Instant::now())); + let total = tracks.len() as u32; + + let mut handles = Vec::with_capacity(tracks.len()); + + for track in tracks { + let sem = semaphore.clone(); + let cli = client.clone(); + let app2 = app.clone(); + let job = job_id.clone(); + let tmpl = template.clone(); + let dest = dest_dir.clone(); + let d = done.clone(); + let s = skipped.clone(); + let f = failed.clone(); + let le = last_emit.clone(); + + handles.push(tokio::spawn(async move { + let _permit = sem.acquire().await.expect("semaphore closed"); + + let relative = apply_device_sync_template(&tmpl, &track); + let file_name = format!("{}.{}", relative, track.suffix); + let dest_path = std::path::Path::new(&dest).join(&file_name); + let path_str = dest_path.to_string_lossy().to_string(); + + let status; + if dest_path.exists() { + s.fetch_add(1, Ordering::Relaxed); + status = "skipped"; + } else { + // Ensure parent directories exist. + if let Some(parent) = dest_path.parent() { + if let Err(e) = tokio::fs::create_dir_all(parent).await { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + } + + let response = match cli.get(&track.url).send().await { + Ok(r) if r.status().is_success() => r, + Ok(r) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": format!("HTTP {}", r.status().as_u16()), + })); + return; + } + Err(e) => { + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + }; + + let part_path = dest_path.with_extension(format!("{}.part", track.suffix)); + if let Err(e) = stream_to_file(response, &part_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e, + })); + return; + } + if let Err(e) = tokio::fs::rename(&part_path, &dest_path).await { + let _ = tokio::fs::remove_file(&part_path).await; + f.fetch_add(1, Ordering::Relaxed); + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": "error", + "error": e.to_string(), + })); + return; + } + + d.fetch_add(1, Ordering::Relaxed); + status = "done"; + } + + // Throttled progress event — max once per 500ms. + let should_emit = { + let mut guard = le.lock().await; + if guard.elapsed() >= Duration::from_millis(500) { + *guard = Instant::now(); + true + } else { + false + } + }; + if should_emit { + let _ = app2.emit("device:sync:progress", serde_json::json!({ + "jobId": job, "trackId": track.id, "status": status, "path": path_str, + "done": d.load(Ordering::Relaxed), + "skipped": s.load(Ordering::Relaxed), + "failed": f.load(Ordering::Relaxed), + "total": total, + })); + } + })); + } + + // Wait for all tasks to complete. + for handle in handles { + let _ = handle.await; + } + + let result = SyncBatchResult { + done: done.load(Ordering::Relaxed), + skipped: skipped.load(Ordering::Relaxed), + failed: failed.load(Ordering::Relaxed), + }; + + // Final event so the frontend always sees 100%. + let _ = app.emit("device:sync:complete", serde_json::json!({ + "jobId": job_id, + "done": result.done, + "skipped": result.skipped, + "failed": result.failed, + "total": total, + })); + + Ok(result) +} + +/// Deletes multiple files from the device in one call and prunes empty parent +/// directories. Returns the number of files successfully deleted. +#[tauri::command] +async fn delete_device_files(paths: Vec) -> Result { + let mut deleted: u32 = 0; + for path in &paths { + let p = std::path::PathBuf::from(path); + if p.exists() { + if tokio::fs::remove_file(&p).await.is_ok() { + deleted += 1; + prune_empty_parents(&p, 2).await; + } + } + } + Ok(deleted) } /// Builds and returns a new system-tray icon with all menu items and event handlers. @@ -1884,6 +2376,7 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ greet, + calculate_sync_payload, exit_app, set_window_decorations, no_compositing_mode, @@ -1932,9 +2425,12 @@ pub fn run() { delete_hot_cache_track, purge_hot_cache, sync_track_to_device, + sync_batch_to_device, compute_sync_paths, list_device_dir_files, delete_device_file, + delete_device_files, + get_removable_drives, toggle_tray_icon, check_dir_accessible, download_zip, diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 863f03ae..bf80a6aa 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) { return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' }; } -function getClient() { +export function getClient() { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); @@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise { - const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { + const data = await api<{ artists: { index: any } }>('getArtists.view', { ...libraryFilterParams(), }); - const indices = data.artists?.index ?? []; - return indices.flatMap(i => i.artist ?? []); + const rawIdx = data.artists?.index; + const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []); + const artists: SubsonicArtist[] = []; + for (const idx of indices) { + const rawArt = idx.artist; + const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []); + artists.push(...arr); + } + return artists; } export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { diff --git a/src/components/AlbumHeader.tsx b/src/components/AlbumHeader.tsx index 901d6807..989fe819 100644 --- a/src/components/AlbumHeader.tsx +++ b/src/components/AlbumHeader.tsx @@ -6,6 +6,7 @@ import CachedImage from './CachedImage'; import CoverLightbox from './CoverLightbox'; import { useTranslation } from 'react-i18next'; import { useIsMobile } from '../hooks/useIsMobile'; +import { useThemeStore } from '../store/themeStore'; import StarRating from './StarRating'; import type { EntityRatingSupportLevel } from '../api/subsonic'; import { useThemeStore } from '../store/themeStore'; @@ -129,6 +130,7 @@ export default function AlbumHeader({ const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / '); + const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); return ( <> diff --git a/src/components/AlbumTrackList.tsx b/src/components/AlbumTrackList.tsx index a7fa9327..64083347 100644 --- a/src/components/AlbumTrackList.tsx +++ b/src/components/AlbumTrackList.tsx @@ -10,6 +10,7 @@ import { AddToPlaylistSubmenu } from './ContextMenu'; import { useIsMobile } from '../hooks/useIsMobile'; import StarRating from './StarRating'; import { useSelectionStore } from '../store/selectionStore'; +import { useThemeStore } from '../store/themeStore'; function formatDuration(seconds: number): string { const h = Math.floor(seconds / 3600); @@ -19,11 +20,11 @@ function formatDuration(seconds: number): string { return `${m}:${s.toString().padStart(2, '0')}`; } -function codecLabel(song: { suffix?: string; bitRate?: number }): string { +function codecLabel(song: { suffix?: string; bitRate?: number }, showBitrate: boolean): string { const parts: string[] = []; if (song.suffix) parts.push(song.suffix.toUpperCase()); - if (song.bitRate) parts.push(`${song.bitRate}`); - return parts.join(' '); + if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`); + return parts.join(' · '); } // ── Column configuration ────────────────────────────────────────────────────── @@ -108,6 +109,7 @@ const TrackRow = React.memo(function TrackRow({ }: TrackRowProps) { const { t } = useTranslation(); const navigate = useNavigate(); + const showBitrate = useThemeStore(s => s.showBitrate); // Fine-grained: only re-renders when THIS row's selection boolean flips. const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); const isActive = currentTrackId === song.id; @@ -192,8 +194,8 @@ const TrackRow = React.memo(function TrackRow({ case 'format': return (
- {(song.suffix || song.bitRate) && ( - {codecLabel(song)} + {(song.suffix || (showBitrate && song.bitRate)) && ( + {codecLabel(song, showBitrate)} )}
); diff --git a/src/components/Hero.tsx b/src/components/Hero.tsx index 2e5959cb..a5fc7213 100644 --- a/src/components/Hero.tsx +++ b/src/components/Hero.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { playAlbum } from '../utils/playAlbum'; import { useIsMobile } from '../hooks/useIsMobile'; import { useAuthStore } from '../store/authStore'; +import { useThemeStore } from '../store/themeStore'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; const INTERVAL_MS = 10000; @@ -58,6 +59,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { const isMobile = useIsMobile(); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled); + const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground); const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum); const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist); const [albums, setAlbums] = useState([]); @@ -141,8 +143,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) { onClick={() => navigate(`/album/${album.id}`)} style={{ cursor: 'pointer' }} > - - +
+
+ +

{t('settings.visualOptionsTitle')}

+
+
+
+
+
{t('settings.coverArtBackground')}
+
{t('settings.coverArtBackgroundSub')}
+
+ +
+
+
+
+
{t('settings.playlistCoverPhoto')}
+
{t('settings.playlistCoverPhotoSub')}
+
+ +
+
+
+
+
{t('settings.showBitrate')}
+
{t('settings.showBitrateSub')}
+
+ +
+
+
+
diff --git a/src/store/deviceSyncJobStore.ts b/src/store/deviceSyncJobStore.ts new file mode 100644 index 00000000..3a9638b2 --- /dev/null +++ b/src/store/deviceSyncJobStore.ts @@ -0,0 +1,36 @@ +import { create } from 'zustand'; + +export interface DeviceSyncJobState { + jobId: string | null; + total: number; + done: number; + skipped: number; + failed: number; + status: 'idle' | 'running' | 'done' | 'cancelled'; + + startSync: (jobId: string, total: number) => void; + updateProgress: (done: number, skipped: number, failed: number) => void; + complete: (done: number, skipped: number, failed: number) => void; + reset: () => void; +} + +export const useDeviceSyncJobStore = create()((set) => ({ + jobId: null, + total: 0, + done: 0, + skipped: 0, + failed: 0, + status: 'idle', + + startSync: (jobId, total) => + set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }), + + updateProgress: (done, skipped, failed) => + set({ done, skipped, failed }), + + complete: (done, skipped, failed) => + set({ done, skipped, failed, status: 'done' }), + + reset: () => + set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }), +})); diff --git a/src/store/deviceSyncStore.ts b/src/store/deviceSyncStore.ts index ec3924c2..c3b3641d 100644 --- a/src/store/deviceSyncStore.ts +++ b/src/store/deviceSyncStore.ts @@ -7,21 +7,14 @@ export interface DeviceSyncSource { name: string; } -export interface DeviceSyncJob { - id: string; - total: number; - done: number; - skipped: number; - failed: number; - status: 'running' | 'done' | 'cancelled'; -} - interface DeviceSyncState { targetDir: string | null; filenameTemplate: string; sources: DeviceSyncSource[]; // persistent device content list - checkedIds: string[]; // currently checked for deletion (not persisted) - activeJob: DeviceSyncJob | null; + checkedIds: string[]; // currently checked for bulk actions (not persisted) + pendingDeletion: string[]; // source IDs marked for deletion (not persisted) + deviceFilePaths: string[]; // actual file paths found on the device (not persisted) + scanning: boolean; // true while scanning the device setTargetDir: (dir: string | null) => void; setFilenameTemplate: (t: string) => void; @@ -30,8 +23,12 @@ interface DeviceSyncState { clearSources: () => void; toggleChecked: (id: string) => void; setCheckedIds: (ids: string[]) => void; - setActiveJob: (job: DeviceSyncJob | null) => void; - updateJob: (update: Partial) => void; + markForDeletion: (ids: string[]) => void; + unmarkDeletion: (id: string) => void; + clearPendingDeletion: () => void; + removeSources: (ids: string[]) => void; + setDeviceFilePaths: (paths: string[]) => void; + setScanning: (v: boolean) => void; } export const useDeviceSyncStore = create()( @@ -41,7 +38,9 @@ export const useDeviceSyncStore = create()( filenameTemplate: '{artist}/{album}/{track_number} - {title}', sources: [], checkedIds: [], - activeJob: null, + pendingDeletion: [], + deviceFilePaths: [], + scanning: false, setTargetDir: (dir) => set({ targetDir: dir }), setFilenameTemplate: (t) => set({ filenameTemplate: t }), @@ -57,9 +56,10 @@ export const useDeviceSyncStore = create()( set((s) => ({ sources: s.sources.filter((x) => x.id !== id), checkedIds: s.checkedIds.filter((x) => x !== id), + pendingDeletion: s.pendingDeletion.filter((x) => x !== id), })), - clearSources: () => set({ sources: [], checkedIds: [] }), + clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }), toggleChecked: (id) => set((s) => ({ @@ -70,12 +70,28 @@ export const useDeviceSyncStore = create()( setCheckedIds: (ids) => set({ checkedIds: ids }), - setActiveJob: (job) => set({ activeJob: job }), - - updateJob: (update) => + markForDeletion: (ids) => set((s) => ({ - activeJob: s.activeJob ? { ...s.activeJob, ...update } : null, + pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])], + checkedIds: s.checkedIds.filter((x) => !ids.includes(x)), })), + + unmarkDeletion: (id) => + set((s) => ({ + pendingDeletion: s.pendingDeletion.filter((x) => x !== id), + })), + + clearPendingDeletion: () => set({ pendingDeletion: [] }), + + removeSources: (ids) => + set((s) => ({ + sources: s.sources.filter((x) => !ids.includes(x.id)), + checkedIds: s.checkedIds.filter((x) => !ids.includes(x)), + pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)), + })), + + setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }), + setScanning: (v) => set({ scanning: v }), }), { name: 'psysonic_device_sync', diff --git a/src/store/themeStore.ts b/src/store/themeStore.ts index 5da21395..320ee4cb 100644 --- a/src/store/themeStore.ts +++ b/src/store/themeStore.ts @@ -57,7 +57,7 @@ export const useThemeStore = create()( setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }), enablePlaylistCoverPhoto: true, setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }), - showBitrate: false, + showBitrate: true, setShowBitrate: (v) => set({ showBitrate: v }), }), { diff --git a/src/styles/components.css b/src/styles/components.css index 04863167..72bebb9d 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -7573,24 +7573,51 @@ html.no-compositing .fs-seekbar-played { .device-sync-header { display: flex; - align-items: center; - gap: 10px; - margin-bottom: 10px; - color: var(--text-primary); + flex-direction: column; + gap: 16px; + margin-bottom: 20px; flex-shrink: 0; } -.device-sync-header h1 { +.device-sync-header-title { + display: flex; + align-items: center; + gap: 10px; + color: var(--text-primary); +} + +.device-sync-header-title h1 { font-size: 1.3rem; font-weight: 600; margin: 0; - flex: 1; } -.device-sync-header-config { +/* ── Config Row (Template + Drive Layout) ── */ + +.device-sync-config-row { display: flex; - align-items: center; - gap: 8px; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 30px; + min-height: 0; + width: 100%; +} + +.device-sync-template-section { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + max-width: 600px; +} + +.device-sync-target-section { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-end; /* Flush right alignment */ + flex: 1; /* allow block expansion */ } .device-sync-folder-path { @@ -7606,28 +7633,104 @@ html.no-compositing .fs-seekbar-played { border-radius: var(--radius-sm); } -/* ── Template row ── */ +/* ── Drive layout ── */ -.device-sync-template-row { +.device-sync-drive-layout { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} + +.device-sync-drive-controls { display: flex; align-items: center; - gap: 10px; - margin-bottom: 14px; + gap: 8px; +} + +.device-sync-drive-meta { + font-size: 0.75rem; + color: var(--text-muted); + text-align: right; + padding-right: 2px; +} + +.device-sync-drive-icon { + color: var(--accent); flex-shrink: 0; } +.device-sync-drive-select { + min-width: 200px; + max-width: 320px; + font-size: 0.95rem; + height: 40px; + padding: 0 10px; + line-height: normal; + display: flex; + align-items: center; + justify-content: space-between; + text-align: left; + cursor: pointer; +} + +.device-sync-drive-controls .btn-ghost { + height: 40px; + width: 40px; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-no-drives { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.78rem; + color: var(--warning, #f59e0b); + padding: 5px 10px; + background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent); + border-radius: var(--radius-sm); +} + .device-sync-label-inline { font-size: 0.8rem; font-weight: 500; color: var(--text-secondary); white-space: nowrap; - flex-shrink: 0; +} + +.device-sync-template-input-wrap { + display: flex; + flex-direction: column; + gap: 4px; + position: relative; } .device-sync-template-input { - flex: 1; + width: 100%; font-family: monospace; - font-size: 0.82rem; + font-size: 0.95rem; + height: 40px; + padding: 0 10px; + line-height: normal; +} + +.device-sync-template-preview { + font-size: 0.72rem; + color: var(--text-muted); + font-family: monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding-left: 2px; + opacity: 0.75; + position: absolute; + top: 100%; + left: 0; + margin-top: 4px; } /* ── Main area (device panel + browser) ── */ @@ -7746,7 +7849,19 @@ html.no-compositing .fs-seekbar-played { } .device-sync-device-row:last-child { border-bottom: none; } -.device-sync-device-row:hover { background: var(--bg-hover); } +.device-sync-source-row:hover { + background: var(--bg-hover); +} + +.device-sync-source-row.deletion, .device-sync-row.deletion { + text-decoration: line-through; + opacity: 0.5; +} + +.device-sync-row-name { + flex: 1; +} + .device-sync-device-row.checked { background: var(--accent-dim); } .device-sync-source-type { @@ -7759,41 +7874,184 @@ html.no-compositing .fs-seekbar-played { flex-shrink: 0; } -/* ── Progress ── */ +/* ── Status summary badges ── */ -.device-sync-progress { +.device-sync-status-summary { display: flex; - flex-direction: column; + align-items: center; gap: 8px; - padding: 10px 14px; - border-top: 1px solid var(--border); + height: 52px; + padding: 0 14px; + border-bottom: 1px solid var(--border); flex-shrink: 0; } -.device-sync-progress-bar-wrap { - height: 4px; +.device-sync-badge { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.device-sync-badge.synced { + background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent); + color: var(--success, #4ade80); +} + +.device-sync-badge.pending { + background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent); + color: var(--warning, #f59e0b); +} + +.device-sync-badge.deletion { + background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent); + color: var(--danger, #f38ba8); +} + +/* ── Status column ── */ + +.device-sync-list-col-status { + width: 50px; + font-size: 0.75rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + flex-shrink: 0; + text-align: center; +} + +.device-sync-list-col-actions { + width: 32px; + flex-shrink: 0; +} + +/* ── Status icons per row ── */ + +.device-sync-status-icon { + width: 50px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-status-icon.synced { color: var(--success, #4ade80); } +.device-sync-status-icon.pending { color: var(--warning, #f59e0b); } +.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); } + +/* ── Per-row action buttons ── */ + +.device-sync-row-actions { + width: 32px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.device-sync-action-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: none; + background: transparent; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background 0.15s, color 0.15s, transform 0.1s; + opacity: 0; +} + +.device-sync-device-row:hover .device-sync-action-btn { + opacity: 1; +} + +.device-sync-action-btn.danger { + color: var(--danger, #f38ba8); +} +.device-sync-action-btn.danger:hover { + background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent); + transform: scale(1.1); +} + +.device-sync-action-btn.muted { + color: var(--text-muted); +} +.device-sync-action-btn.muted:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: scale(1.1); +} + +.device-sync-action-btn.undo { + color: var(--accent); + opacity: 1; +} +.device-sync-action-btn.undo:hover { + background: color-mix(in srgb, var(--accent) 15%, transparent); + transform: scale(1.1); +} + +/* ── Row status variants ── */ + +.device-sync-device-row.synced {} +.device-sync-device-row.pending {} +.device-sync-device-row.deletion { + opacity: 0.55; +} +.device-sync-device-row.deletion .device-sync-row-name { + text-decoration: line-through; + text-decoration-color: var(--danger, #f38ba8); +} + +/* ── Background sync progress (non-blocking) ── */ + +.device-sync-bg-progress { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-top: 1px solid var(--border); + flex-shrink: 0; + background: color-mix(in srgb, var(--accent) 4%, transparent); +} + +.device-sync-bg-progress.done { + flex-direction: row; + align-items: center; + justify-content: space-between; + background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent); +} + +.device-sync-bg-progress-bar-wrap { + height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; } -.device-sync-progress-bar { +.device-sync-bg-progress-bar { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.3s ease; } -.device-sync-progress-stats { +.device-sync-bg-progress-text { display: flex; align-items: center; - gap: 10px; - font-size: 0.82rem; + gap: 6px; + font-size: 0.78rem; color: var(--text-secondary); } -.device-sync-stat-muted { color: var(--text-secondary); display: flex; align-items: center; gap: 3px; } -.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; } +.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; } .color-success { color: var(--success, #4ade80); } /* ── Browser panel ── */ @@ -7842,7 +8100,35 @@ html.no-compositing .fs-seekbar-played { } .device-sync-search-wrap .input { - width: 100%; + flex: 1; +} + +.device-sync-live-badge { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + margin-left: 8px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--accent); + background: var(--accent-dim); + white-space: nowrap; +} + +.device-sync-section-label { + display: flex; + align-items: center; + gap: 5px; + padding: 6px 10px 4px; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); } .device-sync-list { diff --git a/src/styles/layout.css b/src/styles/layout.css index 697b7ae5..7515f2f8 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -912,6 +912,13 @@ background: color-mix(in srgb, var(--accent) 20%, transparent); } +/* ─── Sidebar device-sync queue ─── */ +.sidebar-sync-queue { + background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar)); + border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent); + color: var(--success, #4ade80); +} + @keyframes spin-slow { to { transform: rotate(360deg); } } diff --git a/src/styles/theme.css b/src/styles/theme.css index 2d4c22b9..a4006c88 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -14447,3 +14447,541 @@ input[type="range"]:hover::-webkit-slider-thumb { --danger: #d55e00; --volume-accent: #ffd700; } + +/* ─── COMMUNITY THEMES ─── */ + +/* ─── AMOLED Black Pure ─── */ +[data-theme='amoled-black-pure'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23ffffff%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds — absolute black everywhere */ + --ctp-crust: #000000; + --ctp-mantle: #000000; + --ctp-base: #000000; + --ctp-surface0: #000000; + --ctp-surface1: #050505; + --ctp-surface2: #0a0a0a; + + /* Overlays / muted text — raised for contrast */ + --ctp-overlay0: #555555; + --ctp-overlay1: #707070; + --ctp-overlay2: #8a8a8a; + + /* Text */ + --ctp-text: #ffffff; + --ctp-subtext1: #e0e0e0; + --ctp-subtext0: #bbbbbb; + + /* Accent — pure white */ + --ctp-teal: #ffffff; + --ctp-sky: #ffffff; + --ctp-sapphire: #e0e0e0; + --ctp-blue: #cccccc; + --ctp-lavender: #ffffff; + --ctp-mauve: #e0e0e0; + --ctp-pink: #cccccc; + --ctp-flamingo: #ffffff; + --ctp-rosewater: #e0e0e0; + + /* Semantic */ + --ctp-red: #f87171; + --ctp-maroon: #fb923c; + --ctp-peach: #fb923c; + --ctp-yellow: #fbbf24; + --ctp-green: #4ade80; + + /* UI tokens — all black */ + --bg-app: #000000; + --bg-sidebar: #000000; + --bg-card: #000000; + --bg-hover: #0a0a0a; + --bg-player: #000000; + --bg-glass: rgba(0, 0, 0, 0.95); + + --accent: #ffffff; + --accent-dim: rgba(255, 255, 255, 0.07); + --accent-glow: rgba(255, 255, 255, 0.15); + + --text-primary: #ffffff; + --text-secondary: #b0b0b0; + --text-muted: #777777; + + --border: #141414; + --border-subtle: #0a0a0a; + --border-dropdown: #1f1f1f; + --shadow-dropdown: rgba(0, 0, 0, 0.98); + + --positive: #4ade80; + --warning: #fbbf24; + --danger: #f87171; +} + +/* ─── Monochrome Dark ─── */ +[data-theme='monochrome'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c0c0c0%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds — deep grayscale */ + --ctp-crust: #0d0d0d; + --ctp-mantle: #111111; + --ctp-base: #161616; + --ctp-surface0: #1c1c1c; + --ctp-surface1: #232323; + --ctp-surface2: #2a2a2a; + + /* Overlays — medium grays with good contrast */ + --ctp-overlay0: #555555; + --ctp-overlay1: #6e6e6e; + --ctp-overlay2: #888888; + + /* Text */ + --ctp-text: #e8e8e8; + --ctp-subtext1: #c0c0c0; + --ctp-subtext0: #999999; + + /* Accent — silver / light gray */ + --ctp-teal: #c0c0c0; + --ctp-sky: #c0c0c0; + --ctp-sapphire: #aaaaaa; + --ctp-blue: #999999; + --ctp-lavender: #c0c0c0; + --ctp-mauve: #aaaaaa; + --ctp-pink: #999999; + --ctp-flamingo: #c0c0c0; + --ctp-rosewater: #aaaaaa; + + /* Semantic — desaturated for monochrome */ + --ctp-red: #cc6666; + --ctp-maroon: #bb7744; + --ctp-peach: #bb7744; + --ctp-yellow: #bbaa55; + --ctp-green: #77aa77; + + /* UI tokens */ + --bg-app: #161616; + --bg-sidebar: #111111; + --bg-card: #1c1c1c; + --bg-hover: #232323; + --bg-player: #111111; + --bg-glass: rgba(22, 22, 22, 0.95); + + --accent: #c0c0c0; + --accent-dim: rgba(192, 192, 192, 0.07); + --accent-glow: rgba(192, 192, 192, 0.12); + + --text-primary: #e8e8e8; + --text-secondary: #b0b0b0; + --text-muted: #777777; + + --border: #282828; + --border-subtle: #1f1f1f; + --border-dropdown: #303030; + --shadow-dropdown: rgba(0, 0, 0, 0.85); + + --positive: #77aa77; + --warning: #bbaa55; + --danger: #cc6666; +} + +/* ─── Amber Night ─── */ +[data-theme='amber-night'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23d4a96a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #0a0804; + --ctp-mantle: #0f0c07; + --ctp-base: #1a1410; + --ctp-surface0: #201a12; + --ctp-surface1: #2a2318; + --ctp-surface2: #332b1e; + + /* Overlays */ + --ctp-overlay0: #5a4a30; + --ctp-overlay1: #7a6540; + --ctp-overlay2: #9a8055; + + /* Text */ + --ctp-text: #f0e0c8; + --ctp-subtext1: #d4b88a; + --ctp-subtext0: #b09060; + + /* Golden amber accent */ + --ctp-teal: #d4a96a; + --ctp-sky: #d4a96a; + --ctp-sapphire: #c49055; + --ctp-blue: #b07840; + --ctp-lavender: #d4a96a; + --ctp-mauve: #c49055; + --ctp-pink: #b07840; + --ctp-flamingo: #d4a96a; + --ctp-rosewater: #c49055; + + /* Semantic */ + --ctp-red: #e07060; + --ctp-maroon: #d08050; + --ctp-peach: #d08050; + --ctp-yellow: #d4a96a; + --ctp-green: #80b870; + + /* UI tokens */ + --bg-app: #1a1410; + --bg-sidebar: #0f0c07; + --bg-card: #201a12; + --bg-hover: #2a2318; + --bg-player: #0f0c07; + --bg-glass: rgba(26, 20, 16, 0.95); + + --accent: #d4a96a; + --accent-dim: rgba(212, 169, 106, 0.08); + --accent-glow: rgba(212, 169, 106, 0.15); + + --text-primary: #f0e0c8; + --text-secondary: #b09060; + --text-muted: #7a6040; + + --border: #2a2318; + --border-subtle: #201a12; + --border-dropdown: #332b1e; + --shadow-dropdown: rgba(0, 0, 0, 0.90); + + --positive: #80b870; + --warning: #d4a96a; + --danger: #e07060; +} + +/* ─── Phosphor Green ─── */ +[data-theme='phosphor-green'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%234ade80%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #050e05; + --ctp-mantle: #070f07; + --ctp-base: #0d1a0d; + --ctp-surface0: #111f11; + --ctp-surface1: #162616; + --ctp-surface2: #1c2e1c; + + /* Overlays */ + --ctp-overlay0: #2d5a2d; + --ctp-overlay1: #3d7a3d; + --ctp-overlay2: #4d9a4d; + + /* Text */ + --ctp-text: #d0f0d0; + --ctp-subtext1: #a0d0a0; + --ctp-subtext0: #70aa70; + + /* Phosphor green accent */ + --ctp-teal: #4ade80; + --ctp-sky: #4ade80; + --ctp-sapphire: #38c070; + --ctp-blue: #2aa060; + --ctp-lavender: #4ade80; + --ctp-mauve: #38c070; + --ctp-pink: #2aa060; + --ctp-flamingo: #4ade80; + --ctp-rosewater: #38c070; + + /* Semantic */ + --ctp-red: #e06060; + --ctp-maroon: #c07840; + --ctp-peach: #c07840; + --ctp-yellow: #b0b050; + --ctp-green: #4ade80; + + /* UI tokens */ + --bg-app: #0d1a0d; + --bg-sidebar: #070f07; + --bg-card: #111f11; + --bg-hover: #162616; + --bg-player: #070f07; + --bg-glass: rgba(13, 26, 13, 0.95); + + --accent: #4ade80; + --accent-dim: rgba(74, 222, 128, 0.07); + --accent-glow: rgba(74, 222, 128, 0.14); + + --text-primary: #d0f0d0; + --text-secondary: #70aa70; + --text-muted: #3d6a3d; + + --border: #1c2e1c; + --border-subtle: #111f11; + --border-dropdown: #223022; + --shadow-dropdown: rgba(0, 0, 0, 0.88); + + --positive: #4ade80; + --warning: #b0b050; + --danger: #e06060; +} + +/* ─── Midnight Blue ─── */ +[data-theme='midnight-blue'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%2360a5fa%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #07090f; + --ctp-mantle: #090c14; + --ctp-base: #0d1420; + --ctp-surface0: #111a28; + --ctp-surface1: #162030; + --ctp-surface2: #1c2838; + + /* Overlays */ + --ctp-overlay0: #2a3d5a; + --ctp-overlay1: #3a507a; + --ctp-overlay2: #4a659a; + + /* Text */ + --ctp-text: #ccd8f0; + --ctp-subtext1: #9ab0d8; + --ctp-subtext0: #6888b0; + + /* Light blue accent */ + --ctp-teal: #60a5fa; + --ctp-sky: #60a5fa; + --ctp-sapphire: #4a90e8; + --ctp-blue: #3878d0; + --ctp-lavender: #60a5fa; + --ctp-mauve: #4a90e8; + --ctp-pink: #3878d0; + --ctp-flamingo: #60a5fa; + --ctp-rosewater: #4a90e8; + + /* Semantic */ + --ctp-red: #e07070; + --ctp-maroon: #d09050; + --ctp-peach: #d09050; + --ctp-yellow: #c8b850; + --ctp-green: #60c880; + + /* UI tokens */ + --bg-app: #0d1420; + --bg-sidebar: #090c14; + --bg-card: #111a28; + --bg-hover: #162030; + --bg-player: #090c14; + --bg-glass: rgba(13, 20, 32, 0.95); + + --accent: #60a5fa; + --accent-dim: rgba(96, 165, 250, 0.07); + --accent-glow: rgba(96, 165, 250, 0.14); + + --text-primary: #ccd8f0; + --text-secondary: #6888b0; + --text-muted: #3a5070; + + --border: #1c2838; + --border-subtle: #111a28; + --border-dropdown: #223040; + --shadow-dropdown: rgba(0, 0, 0, 0.90); + + --positive: #60c880; + --warning: #c8b850; + --danger: #e07070; +} + +/* ─── Rose Dark ─── */ +[data-theme='rose-dark'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23f472b6%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #0e080b; + --ctp-mantle: #120a0e; + --ctp-base: #1a0d14; + --ctp-surface0: #20111a; + --ctp-surface1: #281520; + --ctp-surface2: #301a27; + + /* Overlays */ + --ctp-overlay0: #5a2a40; + --ctp-overlay1: #7a3a58; + --ctp-overlay2: #9a5070; + + /* Text */ + --ctp-text: #f0d0e0; + --ctp-subtext1: #d8a0c0; + --ctp-subtext0: #b07090; + + /* Pink accent */ + --ctp-teal: #f472b6; + --ctp-sky: #f472b6; + --ctp-sapphire: #e055a0; + --ctp-blue: #c8408a; + --ctp-lavender: #f472b6; + --ctp-mauve: #e055a0; + --ctp-pink: #c8408a; + --ctp-flamingo: #f472b6; + --ctp-rosewater: #e055a0; + + /* Semantic */ + --ctp-red: #e06070; + --ctp-maroon: #d07850; + --ctp-peach: #d07850; + --ctp-yellow: #c8a850; + --ctp-green: #70b880; + + /* UI tokens */ + --bg-app: #1a0d14; + --bg-sidebar: #120a0e; + --bg-card: #20111a; + --bg-hover: #281520; + --bg-player: #120a0e; + --bg-glass: rgba(26, 13, 20, 0.95); + + --accent: #f472b6; + --accent-dim: rgba(244, 114, 182, 0.07); + --accent-glow: rgba(244, 114, 182, 0.14); + + --text-primary: #f0d0e0; + --text-secondary: #b07090; + --text-muted: #704858; + + --border: #281520; + --border-subtle: #20111a; + --border-dropdown: #341a28; + --shadow-dropdown: rgba(0, 0, 0, 0.90); + + --positive: #70b880; + --warning: #c8a850; + --danger: #e06070; +} + +/* ─── Sepia Dark ─── */ +[data-theme='sepia-dark'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%23c8b89a%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #0e0c09; + --ctp-mantle: #13100c; + --ctp-base: #1e1a14; + --ctp-surface0: #252018; + --ctp-surface1: #2e281e; + --ctp-surface2: #373025; + + /* Overlays */ + --ctp-overlay0: #605545; + --ctp-overlay1: #806e5a; + --ctp-overlay2: #9a8870; + + /* Text */ + --ctp-text: #ede0cc; + --ctp-subtext1: #d0c0a8; + --ctp-subtext0: #b0a080; + + /* Cream accent */ + --ctp-teal: #c8b89a; + --ctp-sky: #c8b89a; + --ctp-sapphire: #b8a080; + --ctp-blue: #a08868; + --ctp-lavender: #c8b89a; + --ctp-mauve: #b8a080; + --ctp-pink: #a08868; + --ctp-flamingo: #c8b89a; + --ctp-rosewater: #b8a080; + + /* Semantic */ + --ctp-red: #c87060; + --ctp-maroon: #c08050; + --ctp-peach: #c08050; + --ctp-yellow: #b8a050; + --ctp-green: #80a870; + + /* UI tokens */ + --bg-app: #1e1a14; + --bg-sidebar: #13100c; + --bg-card: #252018; + --bg-hover: #2e281e; + --bg-player: #13100c; + --bg-glass: rgba(30, 26, 20, 0.95); + + --accent: #c8b89a; + --accent-dim: rgba(200, 184, 154, 0.07); + --accent-glow: rgba(200, 184, 154, 0.13); + + --text-primary: #ede0cc; + --text-secondary: #b0a080; + --text-muted: #706050; + + --border: #2e281e; + --border-subtle: #252018; + --border-dropdown: #3a3025; + --shadow-dropdown: rgba(0, 0, 0, 0.88); + + --positive: #80a870; + --warning: #b8a050; + --danger: #c87060; +} + +/* ─── Ice Blue ─── */ +[data-theme='ice-blue'] { + color-scheme: dark; + --select-arrow: url("data:image/svg+xml;charset=US-ASCII,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22none%22%20stroke%3D%22%237dd3e8%22%20stroke-width%3D%222%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%3E%3Cpolyline%20points%3D%226%209%2012%2015%2018%209%22%3E%3C%2Fpolyline%3E%3C%2Fsvg%3E"); + + /* Backgrounds */ + --ctp-crust: #07101a; + --ctp-mantle: #09141e; + --ctp-base: #0e1d28; + --ctp-surface0: #132430; + --ctp-surface1: #192c3a; + --ctp-surface2: #203444; + + /* Overlays */ + --ctp-overlay0: #2a4a5a; + --ctp-overlay1: #3a6070; + --ctp-overlay2: #4a788a; + + /* Text */ + --ctp-text: #cce8f0; + --ctp-subtext1: #9accd8; + --ctp-subtext0: #68a8b8; + + /* Icy cyan accent */ + --ctp-teal: #7dd3e8; + --ctp-sky: #7dd3e8; + --ctp-sapphire: #60bcd5; + --ctp-blue: #48a8c0; + --ctp-lavender: #7dd3e8; + --ctp-mauve: #60bcd5; + --ctp-pink: #48a8c0; + --ctp-flamingo: #7dd3e8; + --ctp-rosewater: #60bcd5; + + /* Semantic */ + --ctp-red: #e07878; + --ctp-maroon: #d09858; + --ctp-peach: #d09858; + --ctp-yellow: #c8c060; + --ctp-green: #60c898; + + /* UI tokens */ + --bg-app: #0e1d28; + --bg-sidebar: #09141e; + --bg-card: #132430; + --bg-hover: #192c3a; + --bg-player: #09141e; + --bg-glass: rgba(14, 29, 40, 0.95); + + --accent: #7dd3e8; + --accent-dim: rgba(125, 211, 232, 0.07); + --accent-glow: rgba(125, 211, 232, 0.14); + + --text-primary: #cce8f0; + --text-secondary: #68a8b8; + --text-muted: #3a6878; + + --border: #192c3a; + --border-subtle: #132430; + --border-dropdown: #223844; + --shadow-dropdown: rgba(0, 0, 0, 0.90); + + --positive: #60c898; + --warning: #c8c060; + --danger: #e07878; +}