merge branch 'main' into redesign

This commit is contained in:
kveld9
2026-04-14 17:29:57 -03:00
31 changed files with 2648 additions and 298 deletions
+4
View File
@@ -169,6 +169,10 @@ If you want to build Psysonic from source or contribute to the project:
### Prerequisites ### Prerequisites
- [Node.js](https://nodejs.org/) (v18+) - [Node.js](https://nodejs.org/) (v18+)
- [Rust](https://www.rust-lang.org/) (v1.75+) - [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)). - OS-specific build dependencies for Tauri (see the [Tauri prerequisites guide](https://tauri.app/v2/guides/getting-started/prerequisites)).
### Setup ### Setup
+5 -5
View File
@@ -1,12 +1,12 @@
{ {
"name": "psysonic", "name": "psysonic",
"version": "1.34.9", "version": "1.34.11-dev",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "psysonic", "name": "psysonic",
"version": "1.34.9", "version": "1.34.11-dev",
"dependencies": { "dependencies": {
"@fontsource-variable/dm-sans": "^5.2.8", "@fontsource-variable/dm-sans": "^5.2.8",
"@fontsource-variable/figtree": "^5.2.10", "@fontsource-variable/figtree": "^5.2.10",
@@ -2278,9 +2278,9 @@
} }
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
+42
View File
@@ -619,6 +619,15 @@ dependencies = [
"libloading 0.8.9", "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]] [[package]]
name = "cocoa" name = "cocoa"
version = "0.24.1" version = "0.24.1"
@@ -3078,6 +3087,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "ordered-stream" name = "ordered-stream"
version = "0.2.0" version = "0.2.0"
@@ -3581,6 +3599,8 @@ dependencies = [
"serde_json", "serde_json",
"souvlaki", "souvlaki",
"symphonia", "symphonia",
"symphonia-adapter-libopus",
"sysinfo",
"tauri", "tauri",
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
@@ -4739,6 +4759,17 @@ dependencies = [
"symphonia-metadata", "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]] [[package]]
name = "symphonia-bundle-flac" name = "symphonia-bundle-flac"
version = "0.5.5" version = "0.5.5"
@@ -4927,6 +4958,17 @@ dependencies = [
"syn 2.0.117", "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]] [[package]]
name = "system-deps" name = "system-deps"
version = "6.2.2" version = "6.2.2"
+3 -1
View File
@@ -7,7 +7,7 @@ license = ""
repository = "" repository = ""
default-run = "psysonic" default-run = "psysonic"
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.89"
[lib] [lib]
name = "psysonic_lib" name = "psysonic_lib"
@@ -45,7 +45,9 @@ discord-rich-presence = "0.2"
url = "2" url = "2"
thread-priority = "1" thread-priority = "1"
lofty = "0.22" lofty = "0.22"
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
id3 = "1.16.4" id3 = "1.16.4"
symphonia-adapter-libopus = "0.2.7"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" libc = "0.2"
+15 -4
View File
@@ -1,5 +1,5 @@
use std::io::{Cursor, Read, Seek, SeekFrom}; 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::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[cfg(unix)] #[cfg(unix)]
@@ -13,7 +13,7 @@ use rodio::source::UniformSourceIterator;
use serde::Serialize; use serde::Serialize;
use symphonia::core::{ use symphonia::core::{
audio::{AudioBufferRef, SampleBuffer, SignalSpec}, audio::{AudioBufferRef, SampleBuffer, SignalSpec},
codecs::{DecoderOptions, CODEC_TYPE_NULL}, codecs::{CodecRegistry, DecoderOptions, CODEC_TYPE_NULL},
formats::{FormatOptions, FormatReader, SeekMode, SeekTo}, formats::{FormatOptions, FormatReader, SeekMode, SeekTo},
io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions}, io::{MediaSource, MediaSourceStream, MediaSourceStreamOptions},
meta::MetadataOptions, meta::MetadataOptions,
@@ -682,11 +682,22 @@ impl Drop for RadioLiveState {
// [features] // [features]
// fdk-aac = ["dep:symphonia-adapter-fdk-aac"] // fdk-aac = ["dep:symphonia-adapter-fdk-aac"]
/// Symphonias default codec set for our enabled features, plus Opus via libopus.
fn psysonic_codec_registry() -> &'static CodecRegistry {
static REGISTRY: OnceLock<CodecRegistry> = OnceLock::new();
REGISTRY.get_or_init(|| {
let mut registry = CodecRegistry::new();
symphonia::default::register_enabled_codecs(&mut registry);
registry.register_all::<symphonia_adapter_libopus::OpusDecoder>();
registry
})
}
fn try_make_radio_decoder( fn try_make_radio_decoder(
params: &symphonia::core::codecs::CodecParameters, params: &symphonia::core::codecs::CodecParameters,
opts: &DecoderOptions, opts: &DecoderOptions,
) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> { ) -> Result<Box<dyn symphonia::core::codecs::Decoder>, symphonia::core::errors::Error> {
symphonia::default::get_codecs().make(params, opts) psysonic_codec_registry().make(params, opts)
} }
// ── Async HTTP Download Task ────────────────────────────────────────────────── // ── Async HTTP Download Task ──────────────────────────────────────────────────
@@ -980,7 +991,7 @@ impl SizedDecoder {
.zip(track.codec_params.n_frames) .zip(track.codec_params.n_frames)
.map(|(base, frames)| base.calc_time(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()) .make(&track.codec_params, &DecoderOptions::default())
.map_err(|e| { .map_err(|e| {
eprintln!("[psysonic] codec init failed: {e}"); eprintln!("[psysonic] codec init failed: {e}");
+510 -14
View File
@@ -1351,7 +1351,68 @@ async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> R
// ─── Device Sync ───────────────────────────────────────────────────────────── // ─── 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<RemovableDrive> {
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 { struct TrackSyncInfo {
id: String, id: String,
url: String, url: String,
@@ -1366,6 +1427,14 @@ struct TrackSyncInfo {
year: Option<u32>, year: Option<u32>,
} }
/// 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)] #[derive(serde::Serialize)]
struct SyncTrackResult { struct SyncTrackResult {
path: String, path: String,
@@ -1524,22 +1593,445 @@ async fn delete_device_file(path: String) -> Result<(), String> {
let p = std::path::PathBuf::from(&path); let p = std::path::PathBuf::from(&path);
if p.exists() { if p.exists() {
tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?; tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?;
// Prune empty parent dirs (album → artist) prune_empty_parents(&p, 2).await;
let mut current = p.parent().map(|d| d.to_path_buf()); }
for _ in 0..2 { Ok(())
let Some(dir) = current else { break }; }
let is_empty = std::fs::read_dir(&dir)
.map(|mut rd| rd.next().is_none()) /// Prune empty parent directories up to `levels` levels above `file_path`.
.unwrap_or(false); async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) {
if is_empty { let mut current = file_path.parent().map(|d| d.to_path_buf());
let _ = tokio::fs::remove_dir(&dir).await; for _ in 0..levels {
current = dir.parent().map(|d| d.to_path_buf()); let Some(dir) = current else { break };
} else { let is_empty = std::fs::read_dir(&dir)
break; .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<serde_json::Value>,
}
async fn fetch_subsonic_songs(
client: &reqwest::Client,
auth: &SubsonicAuthPayload,
endpoint: &str,
id: &str,
) -> Result<Vec<serde_json::Value>, 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<DeviceSyncSourcePayload>,
deletion_ids: Vec<String>,
auth: SubsonicAuthPayload,
target_dir: String,
template: String,
) -> Result<SyncDeltaResult, String> {
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::<serde_json::Value>().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<TrackSyncInfo>,
dest_dir: String,
template: String,
job_id: String,
expected_bytes: u64,
app: tauri::AppHandle,
) -> Result<SyncBatchResult, String> {
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<String>) -> Result<u32, String> {
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. /// 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![ .invoke_handler(tauri::generate_handler![
greet, greet,
calculate_sync_payload,
exit_app, exit_app,
set_window_decorations, set_window_decorations,
no_compositing_mode, no_compositing_mode,
@@ -1932,9 +2425,12 @@ pub fn run() {
delete_hot_cache_track, delete_hot_cache_track,
purge_hot_cache, purge_hot_cache,
sync_track_to_device, sync_track_to_device,
sync_batch_to_device,
compute_sync_paths, compute_sync_paths,
list_device_dir_files, list_device_dir_files,
delete_device_file, delete_device_file,
delete_device_files,
get_removable_drives,
toggle_tray_icon, toggle_tray_icon,
check_dir_accessible, check_dir_accessible,
download_zip, download_zip,
+11 -4
View File
@@ -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' }; 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 { getBaseUrl, getActiveServer } = useAuthStore.getState();
const server = getActiveServer(); const server = getActiveServer();
const baseUrl = getBaseUrl(); const baseUrl = getBaseUrl();
@@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
} }
export async function getArtists(): Promise<SubsonicArtist[]> { export async function getArtists(): Promise<SubsonicArtist[]> {
const data = await api<{ artists: { index: Array<{ artist: SubsonicArtist[] }> } }>('getArtists.view', { const data = await api<{ artists: { index: any } }>('getArtists.view', {
...libraryFilterParams(), ...libraryFilterParams(),
}); });
const indices = data.artists?.index ?? []; const rawIdx = data.artists?.index;
return indices.flatMap(i => i.artist ?? []); 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[] }> { export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
+2
View File
@@ -6,6 +6,7 @@ import CachedImage from './CachedImage';
import CoverLightbox from './CoverLightbox'; import CoverLightbox from './CoverLightbox';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useThemeStore } from '../store/themeStore';
import StarRating from './StarRating'; import StarRating from './StarRating';
import type { EntityRatingSupportLevel } from '../api/subsonic'; import type { EntityRatingSupportLevel } from '../api/subsonic';
import { useThemeStore } from '../store/themeStore'; import { useThemeStore } from '../store/themeStore';
@@ -129,6 +130,7 @@ export default function AlbumHeader({
const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0); const totalDuration = songs.reduce((acc, s) => acc + s.duration, 0);
const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0); const totalSize = songs.reduce((acc, s) => acc + (s.size ?? 0), 0);
const formatLabel = [...new Set(songs.map(s => s.suffix).filter((f): f is string => !!f))].map(f => f.toUpperCase()).join(' / '); 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 ( return (
<> <>
+7 -5
View File
@@ -10,6 +10,7 @@ import { AddToPlaylistSubmenu } from './ContextMenu';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import StarRating from './StarRating'; import StarRating from './StarRating';
import { useSelectionStore } from '../store/selectionStore'; import { useSelectionStore } from '../store/selectionStore';
import { useThemeStore } from '../store/themeStore';
function formatDuration(seconds: number): string { function formatDuration(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
@@ -19,11 +20,11 @@ function formatDuration(seconds: number): string {
return `${m}:${s.toString().padStart(2, '0')}`; 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[] = []; const parts: string[] = [];
if (song.suffix) parts.push(song.suffix.toUpperCase()); if (song.suffix) parts.push(song.suffix.toUpperCase());
if (song.bitRate) parts.push(`${song.bitRate}`); if (showBitrate && song.bitRate) parts.push(`${song.bitRate} kbps`);
return parts.join(' '); return parts.join(' · ');
} }
// ── Column configuration ────────────────────────────────────────────────────── // ── Column configuration ──────────────────────────────────────────────────────
@@ -108,6 +109,7 @@ const TrackRow = React.memo(function TrackRow({
}: TrackRowProps) { }: TrackRowProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const showBitrate = useThemeStore(s => s.showBitrate);
// Fine-grained: only re-renders when THIS row's selection boolean flips. // Fine-grained: only re-renders when THIS row's selection boolean flips.
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id)); const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
const isActive = currentTrackId === song.id; const isActive = currentTrackId === song.id;
@@ -192,8 +194,8 @@ const TrackRow = React.memo(function TrackRow({
case 'format': case 'format':
return ( return (
<div key="format" className="track-meta"> <div key="format" className="track-meta">
{(song.suffix || song.bitRate) && ( {(song.suffix || (showBitrate && song.bitRate)) && (
<span className="track-codec">{codecLabel(song)}</span> <span className="track-codec">{codecLabel(song, showBitrate)}</span>
)} )}
</div> </div>
); );
+4 -2
View File
@@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next';
import { playAlbum } from '../utils/playAlbum'; import { playAlbum } from '../utils/playAlbum';
import { useIsMobile } from '../hooks/useIsMobile'; import { useIsMobile } from '../hooks/useIsMobile';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter'; import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../utils/mixRatingFilter';
const INTERVAL_MS = 10000; const INTERVAL_MS = 10000;
@@ -58,6 +59,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled); const mixMinRatingFilterEnabled = useAuthStore(s => s.mixMinRatingFilterEnabled);
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum); const mixMinRatingAlbum = useAuthStore(s => s.mixMinRatingAlbum);
const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist); const mixMinRatingArtist = useAuthStore(s => s.mixMinRatingArtist);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]); const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
@@ -141,8 +143,8 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
onClick={() => navigate(`/album/${album.id}`)} onClick={() => navigate(`/album/${album.id}`)}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
> >
<HeroBg url={stableBgUrl.current} /> {enableCoverArtBackground && <HeroBg url={stableBgUrl.current} />}
<div className="hero-overlay" aria-hidden="true" /> {enableCoverArtBackground && <div className="hero-overlay" aria-hidden="true" />}
{/* key causes re-mount → animate-fade-in triggers on each album change */} {/* key causes re-mount → animate-fade-in triggers on each album change */}
<div className="hero-content animate-fade-in" key={album.id}> <div className="hero-content animate-fade-in" key={album.id}>
+20
View File
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useOfflineStore } from '../store/offlineStore'; import { useOfflineStore } from '../store/offlineStore';
import { useOfflineJobStore } from '../store/offlineJobStore'; import { useOfflineJobStore } from '../store/offlineJobStore';
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useSidebarStore } from '../store/sidebarStore'; import { useSidebarStore } from '../store/sidebarStore';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
@@ -50,6 +51,12 @@ export default function Sidebar({
const offlineJobs = useOfflineJobStore(s => s.jobs); const offlineJobs = useOfflineJobStore(s => s.jobs);
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads); const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading'); const activeJobs = offlineJobs.filter(j => j.status === 'queued' || j.status === 'downloading');
const syncJobStatus = useDeviceSyncJobStore(s => s.status);
const syncJobDone = useDeviceSyncJobStore(s => s.done);
const syncJobSkip = useDeviceSyncJobStore(s => s.skipped);
const syncJobFail = useDeviceSyncJobStore(s => s.failed);
const syncJobTotal = useDeviceSyncJobStore(s => s.total);
const isSyncing = syncJobStatus === 'running';
const offlineAlbums = useOfflineStore(s => s.albums); const offlineAlbums = useOfflineStore(s => s.albums);
const serverId = useAuthStore(s => s.activeServerId ?? ''); const serverId = useAuthStore(s => s.activeServerId ?? '');
const isLoggedIn = useAuthStore(s => s.isLoggedIn); const isLoggedIn = useAuthStore(s => s.isLoggedIn);
@@ -369,6 +376,19 @@ export default function Sidebar({
</button> </button>
</div> </div>
)} )}
{isSyncing && (
<div
className={`sidebar-offline-queue sidebar-sync-queue ${isCollapsed ? 'sidebar-offline-queue--collapsed' : ''}`}
data-tooltip={isCollapsed ? t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal }) : undefined}
data-tooltip-pos="right"
>
<HardDriveUpload size={isCollapsed ? 18 : 14} className="spin-slow" />
{!isCollapsed && (
<span>{t('sidebar.syncingTracks', { done: syncJobDone + syncJobSkip + syncJobFail, total: syncJobTotal })}</span>
)}
</div>
)}
</nav> </nav>
</aside> </aside>
); );
+13
View File
@@ -93,6 +93,19 @@ export const THEME_GROUPS: { group: string; themes: ThemeDef[] }[] = [
{ id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' }, { id: 'vintage-tube-radio', label: 'Tube Radio', bg: '#3E2723', card: '#1E110A', accent: '#FF6F00' },
], ],
}, },
{
group: 'COMMUNITY',
themes: [
{ id: 'amber-night', label: 'Amber Night', bg: '#1a1410', card: '#201a12', accent: '#d4a96a' },
{ id: 'amoled-black-pure', label: 'AMOLED Black Pure', bg: '#000000', card: '#000000', accent: '#ffffff' },
{ id: 'ice-blue', label: 'Ice Blue', bg: '#0e1d28', card: '#132430', accent: '#7dd3e8' },
{ id: 'midnight-blue', label: 'Midnight Blue', bg: '#0d1420', card: '#111a28', accent: '#60a5fa' },
{ id: 'monochrome', label: 'Monochrome Dark', bg: '#161616', card: '#1c1c1c', accent: '#c0c0c0' },
{ id: 'phosphor-green', label: 'Phosphor Green', bg: '#0d1a0d', card: '#111f11', accent: '#4ade80' },
{ id: 'rose-dark', label: 'Rose Dark', bg: '#1a0d14', card: '#20111a', accent: '#f472b6' },
{ id: 'sepia-dark', label: 'Sepia Dark', bg: '#1e1a14', card: '#252018', accent: '#c8b89a' },
],
},
{ {
group: 'Mediaplayer', group: 'Mediaplayer',
themes: [ themes: [
+11
View File
@@ -869,6 +869,17 @@ export default function WaveformSeek({ trackId }: Props) {
return () => ro.disconnect(); return () => ro.disconnect();
}, [seekbarStyle]); }, [seekbarStyle]);
// Theme change observer — redraw canvas when theme changes.
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const observer = new MutationObserver(() => {
drawSeekbar(canvas, seekbarStyle, heightsRef.current, progressRef.current, bufferedRef.current, animStateRef.current);
});
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => observer.disconnect();
}, [seekbarStyle]);
const trackIdRef = useRef(trackId); const trackIdRef = useRef(trackId);
trackIdRef.current = trackId; trackIdRef.current = trackId;
const seekRef = useRef(seek); const seekRef = useRef(seek);
+39
View File
@@ -17,6 +17,7 @@ export const deTranslation = {
expand: 'Sidebar einblenden', expand: 'Sidebar einblenden',
collapse: 'Sidebar ausblenden', collapse: 'Sidebar ausblenden',
downloadingTracks: '{{n}} Tracks werden gecacht…', downloadingTracks: '{{n}} Tracks werden gecacht…',
syncingTracks: 'Synchronisiere {{done}}/{{total}}…',
cancelDownload: 'Download abbrechen', cancelDownload: 'Download abbrechen',
offlineLibrary: 'Offline-Bibliothek', offlineLibrary: 'Offline-Bibliothek',
genres: 'Genres', genres: 'Genres',
@@ -661,6 +662,13 @@ export const deTranslation = {
themeSchedulerNightTheme: 'Nacht-Theme', themeSchedulerNightTheme: 'Nacht-Theme',
themeSchedulerNightStart: 'Nacht beginnt um', themeSchedulerNightStart: 'Nacht beginnt um',
themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.', themeSchedulerActiveHint: 'Theme-Zeitplan ist aktiv - Themes werden automatisch gewechselt.',
visualOptionsTitle: 'Visuelle Optionen',
coverArtBackground: 'Cover-Hintergrund',
coverArtBackgroundSub: 'Zeigt verschwommenes Cover als Hintergrund in Album/Playlist-Kopfzeilen',
playlistCoverPhoto: 'Playlist-Coverfoto',
playlistCoverPhotoSub: 'Zeigt Coverfoto-Raster in der Playlist-Detailansicht',
showBitrate: 'Bitrate anzeigen',
showBitrateSub: 'Audio-Bitrate in Track-Listen anzeigen',
uiScaleTitle: 'Interface-Skalierung', uiScaleTitle: 'Interface-Skalierung',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1026,8 +1034,15 @@ export const deTranslation = {
title: 'Gerätesync', title: 'Gerätesync',
targetFolder: 'Zielordner', targetFolder: 'Zielordner',
noFolderChosen: 'Kein Ordner gewählt', noFolderChosen: 'Kein Ordner gewählt',
selectDrive: 'Laufwerk auswählen…',
refreshDrives: 'Laufwerke aktualisieren',
noDrivesDetected: 'Keine Wechseldatenträger erkannt',
browseManual: 'Manuell durchsuchen…',
free: 'frei',
notMountedVolume: 'Ziel befindet sich nicht auf einem eingehängten Laufwerk. Das Gerät wurde möglicherweise getrennt.',
chooseFolder: 'Auswählen…', chooseFolder: 'Auswählen…',
filenameTemplate: 'Dateinamen-Vorlage', filenameTemplate: 'Dateinamen-Vorlage',
targetDevice: 'Zielgerät',
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'Auf dem Gerät', onDevice: 'Auf dem Gerät',
addSources: 'Hinzufügen…', addSources: 'Hinzufügen…',
@@ -1045,6 +1060,10 @@ export const deTranslation = {
tabArtists: 'Künstler', tabArtists: 'Künstler',
searchPlaceholder: 'Suche…', searchPlaceholder: 'Suche…',
syncButton: 'Auf Gerät übertragen', syncButton: 'Auf Gerät übertragen',
actionTransfer: 'Auf Gerät übertragen',
actionDelete: 'Vom Gerät löschen',
actionApplyAll: 'Änderungen synchronisieren',
templatePreview: 'Vorschau',
cancel: 'Abbrechen', cancel: 'Abbrechen',
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.', noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
noSources: 'Bitte mindestens eine Quelle auswählen.', noSources: 'Bitte mindestens eine Quelle auswählen.',
@@ -1052,5 +1071,25 @@ export const deTranslation = {
fetchError: 'Fehler beim Laden der Tracks vom Server.', fetchError: 'Fehler beim Laden der Tracks vom Server.',
syncComplete: 'Übertragung abgeschlossen.', syncComplete: 'Übertragung abgeschlossen.',
dismiss: 'Schließen', dismiss: 'Schließen',
colStatus: 'Status',
statusSynced: 'Synchronisiert',
statusPending: 'Ausstehend',
statusDeletion: 'Löschung',
markForDeletion: 'Zur Löschung markieren',
removeSource: 'Entfernen',
undoDeletion: 'Löschung rückgängig',
syncInBackground: 'Sync gestartet — du kannst die Seite verlassen.',
syncInProgress: '{{done}} / {{total}} Tracks…',
syncSummary: 'Sync-Zusammenfassung',
calculating: 'Payload wird berechnet…',
filesToAdd: 'Hinzuzufügende Dateien:',
filesToDelete: 'Zu löschende Dateien:',
netChange: 'Nettoänderung:',
availableSpace: 'Verfügbarer Speicher:',
spaceWarning: 'Warnung: Das Zielgerät hat nicht genug gemeldeten Speicherplatz.',
proceed: 'Sync durchführen',
notEnoughSpace: 'Nicht genug physischer Speicherplatz erkannt!',
liveSearch: 'Live',
randomAlbumsLabel: 'Zufallsalben',
}, },
}; };
+42 -2
View File
@@ -18,6 +18,7 @@ export const enTranslation = {
collapse: 'Collapse Sidebar', collapse: 'Collapse Sidebar',
downloadingTracks: 'Caching {{n}} tracks…', downloadingTracks: 'Caching {{n}} tracks…',
syncingTracks: 'Syncing {{done}}/{{total}}…',
cancelDownload: 'Cancel download', cancelDownload: 'Cancel download',
offlineLibrary: 'Offline Library', offlineLibrary: 'Offline Library',
genres: 'Genres', genres: 'Genres',
@@ -663,6 +664,13 @@ export const enTranslation = {
themeSchedulerNightTheme: 'Night Theme', themeSchedulerNightTheme: 'Night Theme',
themeSchedulerNightStart: 'Night Starts At', themeSchedulerNightStart: 'Night Starts At',
themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.', themeSchedulerActiveHint: 'Theme Scheduler is active - theme changes are managed automatically.',
visualOptionsTitle: 'Visual Options',
coverArtBackground: 'Cover Art Background',
coverArtBackgroundSub: 'Show blurred cover art as background in album/playlist headers',
playlistCoverPhoto: 'Playlist Cover Photo',
playlistCoverPhotoSub: 'Show cover photo grid in playlist detail view',
showBitrate: 'Show Bitrate',
showBitrateSub: 'Display audio bitrate in track listings',
uiScaleTitle: 'Interface Scale', uiScaleTitle: 'Interface Scale',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1028,15 +1036,23 @@ export const enTranslation = {
title: 'Device Sync', title: 'Device Sync',
targetFolder: 'Target Folder', targetFolder: 'Target Folder',
noFolderChosen: 'No folder chosen', noFolderChosen: 'No folder chosen',
selectDrive: 'Select a drive…',
refreshDrives: 'Refresh drives',
noDrivesDetected: 'No removable drives detected',
browseManual: 'Browse manually…',
free: 'free',
notMountedVolume: 'Target is not on a mounted volume. The drive may have been disconnected.',
chooseFolder: 'Choose…', chooseFolder: 'Choose…',
filenameTemplate: 'Filename Template', filenameTemplate: 'Filename Template',
targetDevice: 'Target Device',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
onDevice: 'On Device', onDevice: 'Device Manager',
addSources: 'Add…', addSources: 'Add…',
colName: 'Name', colName: 'Name',
colType: 'Type', colType: 'Type',
colStatus: 'Status',
syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)', syncResult: '{{done}} transferred, {{skipped}} already up to date ({{total}} total)',
deleteFromDevice: 'Delete from device ({{count}})', deleteFromDevice: 'Mark for deletion ({{count}})',
confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?', confirmDelete: 'Delete {{count}} item(s) from the device: {{names}}?',
deleteComplete: '{{count}} item(s) removed from device.', deleteComplete: '{{count}} item(s) removed from device.',
selectedSources: 'Selected Sources', selectedSources: 'Selected Sources',
@@ -1047,6 +1063,10 @@ export const enTranslation = {
tabArtists: 'Artists', tabArtists: 'Artists',
searchPlaceholder: 'Search…', searchPlaceholder: 'Search…',
syncButton: 'Sync to Device', syncButton: 'Sync to Device',
actionTransfer: 'Transfer to Device',
actionDelete: 'Delete from Device',
actionApplyAll: 'Apply All Changes',
templatePreview: 'Preview',
cancel: 'Cancel', cancel: 'Cancel',
noTargetDir: 'Please choose a target folder first.', noTargetDir: 'Please choose a target folder first.',
noSources: 'Please select at least one source.', noSources: 'Please select at least one source.',
@@ -1054,5 +1074,25 @@ export const enTranslation = {
fetchError: 'Failed to fetch tracks from server.', fetchError: 'Failed to fetch tracks from server.',
syncComplete: 'Sync complete.', syncComplete: 'Sync complete.',
dismiss: 'Dismiss', dismiss: 'Dismiss',
statusSynced: 'Synced',
statusPending: 'Pending',
statusDeletion: 'Deletion',
markForDeletion: 'Mark for deletion',
undoDeletion: 'Undo deletion',
removeSource: 'Remove',
syncInBackground: 'Sync started in background — you can navigate away.',
syncInProgress: '{{done}} / {{total}} tracks…',
scanningDevice: 'Scanning device…',
syncSummary: 'Sync Summary',
calculating: 'Calculating required payload…',
filesToAdd: 'Files to Add:',
filesToDelete: 'Files to Delete:',
netChange: 'Net Change:',
availableSpace: 'Available Disk Space:',
spaceWarning: 'Warning: Target device does not have enough reported space.',
proceed: 'Proceed with Sync',
notEnoughSpace: 'Not enough physical disk space detected!',
liveSearch: 'Live',
randomAlbumsLabel: 'Random Albums',
}, },
}; };
+47
View File
@@ -18,6 +18,7 @@ export const esTranslation = {
collapse: 'Colapsar Barra Lateral', collapse: 'Colapsar Barra Lateral',
downloadingTracks: 'Descargando {{n}} pistas…', downloadingTracks: 'Descargando {{n}} pistas…',
syncingTracks: 'Sincronizando {{done}}/{{total}}…',
cancelDownload: 'Cancelar descarga', cancelDownload: 'Cancelar descarga',
offlineLibrary: 'Biblioteca Offline', offlineLibrary: 'Biblioteca Offline',
genres: 'Géneros', genres: 'Géneros',
@@ -664,6 +665,13 @@ export const esTranslation = {
themeSchedulerNightTheme: 'Tema de Noche', themeSchedulerNightTheme: 'Tema de Noche',
themeSchedulerNightStart: 'Comienza de Noche A las', themeSchedulerNightStart: 'Comienza de Noche A las',
themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.', themeSchedulerActiveHint: 'El Programador de Temas está activo — los cambios de tema se manejan automáticamente.',
visualOptionsTitle: 'Opciones Visuales',
coverArtBackground: 'Fondo de Portada',
coverArtBackgroundSub: 'Mostrar portada desenfocada como fondo en encabezados de álbumes y listas de reproducción',
playlistCoverPhoto: 'Foto de Portada de Playlist',
playlistCoverPhotoSub: 'Mostrar cuadrícula de fotos de portada en la vista detallada de playlists',
showBitrate: 'Mostrar Bitrate',
showBitrateSub: 'Mostrar bitrate de audio en las listas de pistas',
uiScaleTitle: 'Escala de Interfaz', uiScaleTitle: 'Escala de Interfaz',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1029,8 +1037,15 @@ export const esTranslation = {
title: 'Sincronizar dispositivo', title: 'Sincronizar dispositivo',
targetFolder: 'Carpeta de destino', targetFolder: 'Carpeta de destino',
noFolderChosen: 'Ninguna carpeta seleccionada', noFolderChosen: 'Ninguna carpeta seleccionada',
selectDrive: 'Seleccionar unidad…',
refreshDrives: 'Actualizar unidades',
noDrivesDetected: 'No se detectaron unidades extraíbles',
browseManual: 'Explorar manualmente…',
free: 'libre',
notMountedVolume: 'El destino no está en un volumen montado. La unidad puede haberse desconectado.',
chooseFolder: 'Elegir…', chooseFolder: 'Elegir…',
filenameTemplate: 'Plantilla de nombre de archivo', filenameTemplate: 'Plantilla de nombre de archivo',
targetDevice: 'Dispositivo de destino',
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Eliminar archivos fuera de la selección', cleanupButton: 'Eliminar archivos fuera de la selección',
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.', cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
@@ -1044,6 +1059,10 @@ export const esTranslation = {
tabArtists: 'Artistas', tabArtists: 'Artistas',
searchPlaceholder: 'Buscar…', searchPlaceholder: 'Buscar…',
syncButton: 'Sincronizar al dispositivo', syncButton: 'Sincronizar al dispositivo',
actionTransfer: 'Transferir al dispositivo',
actionDelete: 'Eliminar del dispositivo',
actionApplyAll: 'Aplicar todos los cambios',
templatePreview: 'Vista previa',
cancel: 'Cancelar', cancel: 'Cancelar',
noTargetDir: 'Por favor, elige primero una carpeta de destino.', noTargetDir: 'Por favor, elige primero una carpeta de destino.',
noSources: 'Por favor, selecciona al menos una fuente.', noSources: 'Por favor, selecciona al menos una fuente.',
@@ -1051,5 +1070,33 @@ export const esTranslation = {
fetchError: 'Error al obtener pistas del servidor.', fetchError: 'Error al obtener pistas del servidor.',
syncComplete: 'Sincronización completada.', syncComplete: 'Sincronización completada.',
dismiss: 'Cerrar', dismiss: 'Cerrar',
colName: 'Nombre',
colType: 'Tipo',
colStatus: 'Estado',
onDevice: 'Gestor de dispositivo',
addSources: 'Agregar…',
syncResult: '{{done}} transferido(s), {{skipped}} ya actualizado(s) ({{total}} total)',
deleteFromDevice: 'Marcar para eliminar ({{count}})',
confirmDelete: '¿Eliminar {{count}} elemento(s) del dispositivo: {{names}}?',
deleteComplete: '{{count}} elemento(s) eliminado(s) del dispositivo.',
statusSynced: 'Sincronizado',
statusPending: 'Pendiente',
statusDeletion: 'Eliminación',
markForDeletion: 'Marcar para eliminar',
removeSource: 'Eliminar',
undoDeletion: 'Deshacer eliminación',
syncInBackground: 'Sincronización iniciada en segundo plano — puedes navegar a otro lugar.',
syncInProgress: '{{done}} / {{total}} pistas…',
syncSummary: 'Resumen de sincronización',
calculating: 'Calculando la carga útil requerida…',
filesToAdd: 'Archivos a agregar:',
filesToDelete: 'Archivos a eliminar:',
netChange: 'Cambio neto:',
availableSpace: 'Espacio disponible en disco:',
spaceWarning: 'Advertencia: El dispositivo de destino no tiene suficiente espacio reportado.',
proceed: 'Proceder con la sincronización',
notEnoughSpace: '¡Espacio físico en disco insuficiente detectado!',
liveSearch: 'Live',
randomAlbumsLabel: 'Álbumes aleatorios',
}, },
}; };
+47
View File
@@ -17,6 +17,7 @@ export const frTranslation = {
expand: 'Développer la barre latérale', expand: 'Développer la barre latérale',
collapse: 'Réduire la barre latérale', collapse: 'Réduire la barre latérale',
downloadingTracks: '{{n}} pistes en cache…', downloadingTracks: '{{n}} pistes en cache…',
syncingTracks: 'Synchro {{done}}/{{total}}…',
cancelDownload: 'Annuler le téléchargement', cancelDownload: 'Annuler le téléchargement',
offlineLibrary: 'Bibliothèque hors ligne', offlineLibrary: 'Bibliothèque hors ligne',
genres: 'Genres', genres: 'Genres',
@@ -659,6 +660,13 @@ export const frTranslation = {
themeSchedulerNightTheme: 'Thème de nuit', themeSchedulerNightTheme: 'Thème de nuit',
themeSchedulerNightStart: 'Début de la nuit', themeSchedulerNightStart: 'Début de la nuit',
themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.', themeSchedulerActiveHint: 'Le planificateur de thème est actif - les thèmes changent automatiquement.',
visualOptionsTitle: 'Options Visuelles',
coverArtBackground: "Fond d'Art de Poche",
coverArtBackgroundSub: "Afficher la pochette floutée comme fond dans les en-têtes d'albums et de playlists",
playlistCoverPhoto: 'Photo de Couverture de Playlist',
playlistCoverPhotoSub: 'Afficher la grille de photos de couverture dans la vue détaillée des playlists',
showBitrate: 'Afficher le Débit',
showBitrateSub: 'Afficher le débit audio dans les listes de pistes',
uiScaleTitle: "Mise à l'échelle de l'interface", uiScaleTitle: "Mise à l'échelle de l'interface",
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1024,8 +1032,15 @@ export const frTranslation = {
title: 'Sync appareil', title: 'Sync appareil',
targetFolder: 'Dossier cible', targetFolder: 'Dossier cible',
noFolderChosen: 'Aucun dossier sélectionné', noFolderChosen: 'Aucun dossier sélectionné',
selectDrive: 'Sélectionner un lecteur…',
refreshDrives: 'Actualiser les lecteurs',
noDrivesDetected: 'Aucun lecteur amovible détecté',
browseManual: 'Parcourir manuellement…',
free: 'libre',
notMountedVolume: 'La cible n\u0027est pas sur un volume monté. Le lecteur a peut-être été déconnecté.',
chooseFolder: 'Choisir…', chooseFolder: 'Choisir…',
filenameTemplate: 'Modèle de nom de fichier', filenameTemplate: 'Modèle de nom de fichier',
targetDevice: 'Appareil cible',
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Supprimer les fichiers absents', cleanupButton: 'Supprimer les fichiers absents',
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.', cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
@@ -1039,6 +1054,10 @@ export const frTranslation = {
tabArtists: 'Artistes', tabArtists: 'Artistes',
searchPlaceholder: 'Rechercher…', searchPlaceholder: 'Rechercher…',
syncButton: 'Synchroniser vers l\'appareil', syncButton: 'Synchroniser vers l\'appareil',
actionTransfer: 'Transférer vers l\'appareil',
actionDelete: 'Supprimer de l\'appareil',
actionApplyAll: 'Appliquer toutes les modifications',
templatePreview: 'Aperçu',
cancel: 'Annuler', cancel: 'Annuler',
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.', noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
noSources: 'Veuillez sélectionner au moins une source.', noSources: 'Veuillez sélectionner au moins une source.',
@@ -1046,5 +1065,33 @@ export const frTranslation = {
fetchError: 'Échec du chargement des pistes depuis le serveur.', fetchError: 'Échec du chargement des pistes depuis le serveur.',
syncComplete: 'Synchronisation terminée.', syncComplete: 'Synchronisation terminée.',
dismiss: 'Fermer', dismiss: 'Fermer',
colName: 'Nom',
colType: 'Type',
colStatus: 'Statut',
onDevice: 'Gestionnaire d\'appareil',
addSources: 'Ajouter…',
syncResult: '{{done}} transféré(s), {{skipped}} déjà à jour ({{total}} au total)',
deleteFromDevice: 'Marquer pour suppression ({{count}})',
confirmDelete: 'Supprimer {{count}} élément(s) du périphérique : {{names}} ?',
deleteComplete: '{{count}} élément(s) supprimé(s) du périphérique.',
statusSynced: 'Synchronisé',
statusPending: 'En attente',
statusDeletion: 'Suppression',
markForDeletion: 'Marquer pour suppression',
removeSource: 'Supprimer',
undoDeletion: 'Annuler la suppression',
syncInBackground: 'Sync démarré en arrière-plan — vous pouvez naviguer ailleurs.',
syncInProgress: '{{done}} / {{total}} pistes…',
syncSummary: 'Résumé de la synchronisation',
calculating: 'Calcul de la charge utile…',
filesToAdd: 'Fichiers à ajouter :',
filesToDelete: 'Fichiers à supprimer :',
netChange: 'Variation nette :',
availableSpace: 'Espace disque disponible :',
spaceWarning: 'Attention : L\'appareil cible ne dispose pas d\'assez d\'espace signalé.',
proceed: 'Procéder à la synchronisation',
notEnoughSpace: 'Espace disque physique insuffisant détecté !',
liveSearch: 'Live',
randomAlbumsLabel: 'Albums aléatoires',
}, },
}; };
+47
View File
@@ -17,6 +17,7 @@ export const nbTranslation = {
expand: 'Utvid sidefelt', expand: 'Utvid sidefelt',
collapse: 'Skjul sidefelt', collapse: 'Skjul sidefelt',
downloadingTracks: 'Bufre {{n}} spor…', downloadingTracks: 'Bufre {{n}} spor…',
syncingTracks: 'Synkroniserer {{done}}/{{total}}…',
cancelDownload: 'Avbryt nedlasting', cancelDownload: 'Avbryt nedlasting',
offlineLibrary: 'Frakoblet bibliotek', offlineLibrary: 'Frakoblet bibliotek',
genres: 'Sjangere', genres: 'Sjangere',
@@ -658,6 +659,13 @@ export const nbTranslation = {
themeSchedulerNightTheme: 'Natttema', themeSchedulerNightTheme: 'Natttema',
themeSchedulerNightStart: 'Natt starter kl.', themeSchedulerNightStart: 'Natt starter kl.',
themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.', themeSchedulerActiveHint: 'Temaplanlegger er aktiv - temaer byttes automatisk.',
visualOptionsTitle: 'Visuelle Alternativer',
coverArtBackground: 'Cover-bakgrunn',
coverArtBackgroundSub: 'Vis uskarpt cover som bakgrunn i album/playlist-overskrifter',
playlistCoverPhoto: 'Playlist-coverfoto',
playlistCoverPhotoSub: 'Vis coverfoto-rutenett i playlist-detailedvisning',
showBitrate: 'Vis Bitrate',
showBitrateSub: 'Vis audio-bitrate i sporlister',
uiScaleTitle: 'Grensesnittskala', uiScaleTitle: 'Grensesnittskala',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1023,8 +1031,15 @@ export const nbTranslation = {
title: 'Enhetssynk', title: 'Enhetssynk',
targetFolder: 'Målmappe', targetFolder: 'Målmappe',
noFolderChosen: 'Ingen mappe valgt', noFolderChosen: 'Ingen mappe valgt',
selectDrive: 'Velg en stasjon…',
refreshDrives: 'Oppdater stasjoner',
noDrivesDetected: 'Ingen flyttbare stasjoner oppdaget',
browseManual: 'Bla manuelt…',
free: 'ledig',
notMountedVolume: 'Målet er ikke på en montert stasjon. Enheten kan ha blitt koblet fra.',
chooseFolder: 'Velg…', chooseFolder: 'Velg…',
filenameTemplate: 'Filnavnmal', filenameTemplate: 'Filnavnmal',
targetDevice: 'Målenhet',
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Fjern filer som ikke er i utvalget', cleanupButton: 'Fjern filer som ikke er i utvalget',
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.', cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
@@ -1038,6 +1053,10 @@ export const nbTranslation = {
tabArtists: 'Artister', tabArtists: 'Artister',
searchPlaceholder: 'Søk…', searchPlaceholder: 'Søk…',
syncButton: 'Synkroniser til enhet', syncButton: 'Synkroniser til enhet',
actionTransfer: 'Overfør til enhet',
actionDelete: 'Slett fra enhet',
actionApplyAll: 'Bruk alle endringer',
templatePreview: 'Forhåndsvisning',
cancel: 'Avbryt', cancel: 'Avbryt',
noTargetDir: 'Velg en målmappe først.', noTargetDir: 'Velg en målmappe først.',
noSources: 'Velg minst én kilde.', noSources: 'Velg minst én kilde.',
@@ -1045,5 +1064,33 @@ export const nbTranslation = {
fetchError: 'Kunne ikke hente spor fra serveren.', fetchError: 'Kunne ikke hente spor fra serveren.',
syncComplete: 'Synkronisering fullført.', syncComplete: 'Synkronisering fullført.',
dismiss: 'Lukk', dismiss: 'Lukk',
colName: 'Navn',
colType: 'Type',
colStatus: 'Status',
onDevice: 'Enhetsbehandling',
addSources: 'Legg til…',
syncResult: '{{done}} overført, {{skipped}} allerede oppdatert ({{total}} totalt)',
deleteFromDevice: 'Merk for sletting ({{count}})',
confirmDelete: 'Slette {{count}} element(er) fra enheten: {{names}}?',
deleteComplete: '{{count}} element(er) fjernet fra enheten.',
statusSynced: 'Synkronisert',
statusPending: 'Venter',
statusDeletion: 'Sletting',
markForDeletion: 'Merk for sletting',
removeSource: 'Fjern',
undoDeletion: 'Angre sletting',
syncInBackground: 'Synkronisering startet i bakgrunnen — du kan navigere bort.',
syncInProgress: '{{done}} / {{total}} spor…',
syncSummary: 'Synkroniseringssammendrag',
calculating: 'Beregner nødvendig nyttelast…',
filesToAdd: 'Filer som skal legges til:',
filesToDelete: 'Filer som skal slettes:',
netChange: 'Nettoendring:',
availableSpace: 'Tilgjengelig diskplass:',
spaceWarning: 'Advarsel: Målenheten har ikke nok rapportert plass.',
proceed: 'Fortsett med synkronisering',
notEnoughSpace: 'Ikke nok fysisk diskplass oppdaget!',
liveSearch: 'Live',
randomAlbumsLabel: 'Tilfeldige album',
}, },
}; };
+47
View File
@@ -17,6 +17,7 @@ export const nlTranslation = {
expand: 'Zijbalk uitklappen', expand: 'Zijbalk uitklappen',
collapse: 'Zijbalk inklappen', collapse: 'Zijbalk inklappen',
downloadingTracks: '{{n}} nummers worden gecached…', downloadingTracks: '{{n}} nummers worden gecached…',
syncingTracks: 'Synchroniseren {{done}}/{{total}}…',
cancelDownload: 'Download annuleren', cancelDownload: 'Download annuleren',
offlineLibrary: 'Offline bibliotheek', offlineLibrary: 'Offline bibliotheek',
genres: 'Genres', genres: 'Genres',
@@ -658,6 +659,13 @@ export const nlTranslation = {
themeSchedulerNightTheme: 'Nachtthema', themeSchedulerNightTheme: 'Nachtthema',
themeSchedulerNightStart: 'Nacht begint om', themeSchedulerNightStart: 'Nacht begint om',
themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.", themeSchedulerActiveHint: "Thema-planner is actief - thema's worden automatisch gewisseld.",
visualOptionsTitle: 'Visuele Opties',
coverArtBackground: 'Cover Achtergrond',
coverArtBackgroundSub: 'Toon vervaagde cover als achtergrond in album/playlist headers',
playlistCoverPhoto: 'Playlist Coverfoto',
playlistCoverPhotoSub: 'Toon coverfoto raster in playlist detailweergave',
showBitrate: 'Toon Bitrate',
showBitrateSub: 'Toon audio bitrate in tracklijsten',
uiScaleTitle: 'Interface schaal', uiScaleTitle: 'Interface schaal',
uiScaleLabel: 'Zoom', uiScaleLabel: 'Zoom',
}, },
@@ -1021,8 +1029,15 @@ export const nlTranslation = {
title: 'Apparaatsync', title: 'Apparaatsync',
targetFolder: 'Doelmap', targetFolder: 'Doelmap',
noFolderChosen: 'Geen map gekozen', noFolderChosen: 'Geen map gekozen',
selectDrive: 'Selecteer een schijf…',
refreshDrives: 'Schijven vernieuwen',
noDrivesDetected: 'Geen verwijderbare schijven gedetecteerd',
browseManual: 'Handmatig bladeren…',
free: 'vrij',
notMountedVolume: 'Het doel bevindt zich niet op een gekoppeld volume. De schijf is mogelijk losgekoppeld.',
chooseFolder: 'Kiezen…', chooseFolder: 'Kiezen…',
filenameTemplate: 'Bestandsnaamsjabloon', filenameTemplate: 'Bestandsnaamsjabloon',
targetDevice: 'Doelapparaat',
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Niet-geselecteerde bestanden verwijderen', cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.', cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
@@ -1036,6 +1051,10 @@ export const nlTranslation = {
tabArtists: 'Artiesten', tabArtists: 'Artiesten',
searchPlaceholder: 'Zoeken…', searchPlaceholder: 'Zoeken…',
syncButton: 'Synchroniseren naar apparaat', syncButton: 'Synchroniseren naar apparaat',
actionTransfer: 'Overdragen naar apparaat',
actionDelete: 'Verwijderen van apparaat',
actionApplyAll: 'Alle wijzigingen toepassen',
templatePreview: 'Voorbeeld',
cancel: 'Annuleren', cancel: 'Annuleren',
noTargetDir: 'Kies eerst een doelmap.', noTargetDir: 'Kies eerst een doelmap.',
noSources: 'Selecteer minimaal één bron.', noSources: 'Selecteer minimaal één bron.',
@@ -1043,5 +1062,33 @@ export const nlTranslation = {
fetchError: 'Ophalen van nummers van de server mislukt.', fetchError: 'Ophalen van nummers van de server mislukt.',
syncComplete: 'Synchronisatie voltooid.', syncComplete: 'Synchronisatie voltooid.',
dismiss: 'Sluiten', dismiss: 'Sluiten',
colName: 'Naam',
colType: 'Type',
colStatus: 'Status',
onDevice: 'Apparaatbeheer',
addSources: 'Toevoegen…',
syncResult: '{{done}} overgedragen, {{skipped}} al up-to-date ({{total}} totaal)',
deleteFromDevice: 'Markeren voor verwijdering ({{count}})',
confirmDelete: '{{count}} item(s) van het apparaat verwijderen: {{names}}?',
deleteComplete: '{{count}} item(s) van het apparaat verwijderd.',
statusSynced: 'Gesynchroniseerd',
statusPending: 'In behandeling',
statusDeletion: 'Verwijdering',
markForDeletion: 'Markeren voor verwijdering',
removeSource: 'Verwijderen',
undoDeletion: 'Verwijdering ongedaan maken',
syncInBackground: 'Sync gestart op achtergrond — u kunt navigeren.',
syncInProgress: '{{done}} / {{total}} nummers…',
syncSummary: 'Synchronisatieoverzicht',
calculating: 'Vereiste payload berekenen…',
filesToAdd: 'Te toevoegen bestanden:',
filesToDelete: 'Te verwijderen bestanden:',
netChange: 'Nettoverandering:',
availableSpace: 'Beschikbare schijfruimte:',
spaceWarning: 'Waarschuwing: Het doelapparaat heeft niet genoeg gerapporteerde ruimte.',
proceed: 'Doorgaan met synchronisatie',
notEnoughSpace: 'Onvoldoende fysieke schijfruimte gedetecteerd!',
liveSearch: 'Live',
randomAlbumsLabel: 'Willekeurige albums',
}, },
}; };
+47
View File
@@ -18,6 +18,7 @@ export const ruTranslation = {
expand: 'Развернуть боковую панель', expand: 'Развернуть боковую панель',
collapse: 'Свернуть боковую панель', collapse: 'Свернуть боковую панель',
downloadingTracks: 'Кэширование {{n}} треков…', downloadingTracks: 'Кэширование {{n}} треков…',
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
cancelDownload: 'Отменить загрузку', cancelDownload: 'Отменить загрузку',
offlineLibrary: 'Офлайн-библиотека', offlineLibrary: 'Офлайн-библиотека',
genres: 'Жанры', genres: 'Жанры',
@@ -686,6 +687,13 @@ export const ruTranslation = {
themeSchedulerNightTheme: 'Ночная тема', themeSchedulerNightTheme: 'Ночная тема',
themeSchedulerNightStart: 'Ночь начинается в', themeSchedulerNightStart: 'Ночь начинается в',
themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.', themeSchedulerActiveHint: 'Расписание тем активно - темы переключаются автоматически.',
visualOptionsTitle: 'Визуальные Настройки',
coverArtBackground: 'Фон Обложки',
coverArtBackgroundSub: 'Показывать размытую обложку как фон в заголовках альбомов и плейлистов',
playlistCoverPhoto: 'Обложка Плейлиста',
playlistCoverPhotoSub: 'Показывать сетку обложек в детальном виде плейлиста',
showBitrate: 'Показывать Битрейт',
showBitrateSub: 'Отображать битрейт аудио в списках треков',
uiScaleTitle: 'Масштаб интерфейса', uiScaleTitle: 'Масштаб интерфейса',
uiScaleLabel: 'Масштаб', uiScaleLabel: 'Масштаб',
}, },
@@ -1082,8 +1090,15 @@ export const ruTranslation = {
title: 'Синхронизация устройства', title: 'Синхронизация устройства',
targetFolder: 'Папка назначения', targetFolder: 'Папка назначения',
noFolderChosen: 'Папка не выбрана', noFolderChosen: 'Папка не выбрана',
selectDrive: 'Выберите диск…',
refreshDrives: 'Обновить диски',
noDrivesDetected: 'Съёмные диски не обнаружены',
browseManual: 'Обзор вручную…',
free: 'свободно',
notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.',
chooseFolder: 'Выбрать…', chooseFolder: 'Выбрать…',
filenameTemplate: 'Шаблон имени файла', filenameTemplate: 'Шаблон имени файла',
targetDevice: 'Целевое устройство',
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}', templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
cleanupButton: 'Удалить файлы вне выборки', cleanupButton: 'Удалить файлы вне выборки',
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.', cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
@@ -1097,6 +1112,10 @@ export const ruTranslation = {
tabArtists: 'Исполнители', tabArtists: 'Исполнители',
searchPlaceholder: 'Поиск…', searchPlaceholder: 'Поиск…',
syncButton: 'Синхронизировать на устройство', syncButton: 'Синхронизировать на устройство',
actionTransfer: 'Перенести на устройство',
actionDelete: 'Удалить с устройства',
actionApplyAll: 'Применить все изменения',
templatePreview: 'Предпросмотр',
cancel: 'Отмена', cancel: 'Отмена',
noTargetDir: 'Сначала выберите папку назначения.', noTargetDir: 'Сначала выберите папку назначения.',
noSources: 'Выберите хотя бы один источник.', noSources: 'Выберите хотя бы один источник.',
@@ -1104,5 +1123,33 @@ export const ruTranslation = {
fetchError: 'Ошибка загрузки треков с сервера.', fetchError: 'Ошибка загрузки треков с сервера.',
syncComplete: 'Синхронизация завершена.', syncComplete: 'Синхронизация завершена.',
dismiss: 'Закрыть', dismiss: 'Закрыть',
colName: 'Название',
colType: 'Тип',
colStatus: 'Статус',
onDevice: 'Управление устройством',
addSources: 'Добавить…',
syncResult: '{{done}} перенесено, {{skipped}} уже актуально ({{total}} всего)',
deleteFromDevice: 'Пометить для удаления ({{count}})',
confirmDelete: 'Удалить {{count}} запись(ей) с устройства: {{names}}?',
deleteComplete: '{{count}} запись(ей) удалено с устройства.',
statusSynced: 'Синхронизировано',
statusPending: 'Ожидает',
statusDeletion: 'Удаление',
markForDeletion: 'Пометить для удаления',
removeSource: 'Удалить',
undoDeletion: 'Отменить удаление',
syncInBackground: 'Синхронизация запущена в фоне — можно перейти в другой раздел.',
syncInProgress: '{{done}} / {{total}} треков…',
syncSummary: 'Сводка синхронизации',
calculating: 'Вычисление необходимых данных…',
filesToAdd: 'Файлы для добавления:',
filesToDelete: 'Файлы для удаления:',
netChange: 'Чистое изменение:',
availableSpace: 'Доступное место на диске:',
spaceWarning: 'Предупреждение: на целевом устройстве недостаточно сообщаемого места.',
proceed: 'Продолжить синхронизацию',
notEnoughSpace: 'Обнаружено недостаточно физического места на диске!',
liveSearch: 'Live',
randomAlbumsLabel: 'Случайные альбомы',
}, },
}; };
+47
View File
@@ -17,6 +17,7 @@ export const zhTranslation = {
expand: '展开侧边栏', expand: '展开侧边栏',
collapse: '收起侧边栏', collapse: '收起侧边栏',
downloadingTracks: '正在缓存 {{n}} 首歌曲…', downloadingTracks: '正在缓存 {{n}} 首歌曲…',
syncingTracks: '同步中 {{done}}/{{total}}…',
cancelDownload: '取消下载', cancelDownload: '取消下载',
offlineLibrary: '离线音乐库', offlineLibrary: '离线音乐库',
genres: '流派', genres: '流派',
@@ -654,6 +655,13 @@ export const zhTranslation = {
themeSchedulerNightTheme: '夜晚主题', themeSchedulerNightTheme: '夜晚主题',
themeSchedulerNightStart: '夜晚开始时间', themeSchedulerNightStart: '夜晚开始时间',
themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。', themeSchedulerActiveHint: '主题定时器已启用 - 主题将自动切换。',
visualOptionsTitle: '视觉选项',
coverArtBackground: '封面背景',
coverArtBackgroundSub: '在专辑/播放列表标题中显示模糊的封面作为背景',
playlistCoverPhoto: '播放列表封面照片',
playlistCoverPhotoSub: '在播放列表详细视图中显示封面照片网格',
showBitrate: '显示比特率',
showBitrateSub: '在曲目列表中显示音频比特率',
uiScaleTitle: '界面缩放', uiScaleTitle: '界面缩放',
uiScaleLabel: '缩放', uiScaleLabel: '缩放',
}, },
@@ -1017,8 +1025,15 @@ export const zhTranslation = {
title: '设备同步', title: '设备同步',
targetFolder: '目标文件夹', targetFolder: '目标文件夹',
noFolderChosen: '未选择文件夹', noFolderChosen: '未选择文件夹',
selectDrive: '选择驱动器…',
refreshDrives: '刷新驱动器',
noDrivesDetected: '未检测到可移动驱动器',
browseManual: '手动浏览…',
free: '可用',
notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。',
chooseFolder: '选择…', chooseFolder: '选择…',
filenameTemplate: '文件名模板', filenameTemplate: '文件名模板',
targetDevice: '目标设备',
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}', templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
cleanupButton: '删除不在选择范围内的文件', cleanupButton: '删除不在选择范围内的文件',
cleanupNothingToDelete: '无需删除 — 设备已同步。', cleanupNothingToDelete: '无需删除 — 设备已同步。',
@@ -1032,6 +1047,10 @@ export const zhTranslation = {
tabArtists: '艺术家', tabArtists: '艺术家',
searchPlaceholder: '搜索…', searchPlaceholder: '搜索…',
syncButton: '同步到设备', syncButton: '同步到设备',
actionTransfer: '传输到设备',
actionDelete: '从设备删除',
actionApplyAll: '应用所有更改',
templatePreview: '预览',
cancel: '取消', cancel: '取消',
noTargetDir: '请先选择目标文件夹。', noTargetDir: '请先选择目标文件夹。',
noSources: '请至少选择一个来源。', noSources: '请至少选择一个来源。',
@@ -1039,5 +1058,33 @@ export const zhTranslation = {
fetchError: '从服务器加载曲目失败。', fetchError: '从服务器加载曲目失败。',
syncComplete: '同步完成。', syncComplete: '同步完成。',
dismiss: '关闭', dismiss: '关闭',
colName: '名称',
colType: '类型',
colStatus: '状态',
onDevice: '设备管理器',
addSources: '添加…',
syncResult: '已传输 {{done}}{{skipped}} 已是最新(共 {{total}}',
deleteFromDevice: '标记为删除({{count}}',
confirmDelete: '从设备删除 {{count}} 个项目:{{names}}',
deleteComplete: '已从设备删除 {{count}} 个项目。',
statusSynced: '已同步',
statusPending: '待处理',
statusDeletion: '删除中',
markForDeletion: '标记为删除',
removeSource: '移除',
undoDeletion: '撤销删除',
syncInBackground: '同步已在后台启动 — 您可以离开此页面。',
syncInProgress: '{{done}} / {{total}} 首曲目…',
syncSummary: '同步摘要',
calculating: '正在计算所需数据…',
filesToAdd: '要添加的文件:',
filesToDelete: '要删除的文件:',
netChange: '净变化:',
availableSpace: '可用磁盘空间:',
spaceWarning: '警告:目标设备的可用空间不足。',
proceed: '继续同步',
notEnoughSpace: '检测到物理磁盘空间不足!',
liveSearch: '实时',
randomAlbumsLabel: '随机专辑',
}, },
}; };
+656 -209
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -117,6 +117,10 @@ export default function PlaylistDetail() {
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto); const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const showBitrate = useThemeStore(s => s.showBitrate); const showBitrate = useThemeStore(s => s.showBitrate);
const enableCoverArtBackground = useThemeStore(s => s.enableCoverArtBackground);
const enablePlaylistCoverPhoto = useThemeStore(s => s.enablePlaylistCoverPhoto);
const showBitrate = useThemeStore(s => s.showBitrate);
const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null); const [playlist, setPlaylist] = useState<SubsonicPlaylist | null>(null);
const [songs, setSongs] = useState<SubsonicSong[]>([]); const [songs, setSongs] = useState<SubsonicSong[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
+3 -1
View File
@@ -8,6 +8,7 @@ import ArtistRow from '../components/ArtistRow';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDragDrop } from '../contexts/DragDropContext'; import { useDragDrop } from '../contexts/DragDropContext';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useThemeStore } from '../store/themeStore';
function formatDuration(s: number) { function formatDuration(s: number) {
return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`; return `${Math.floor(s / 60)}:${(s % 60).toString().padStart(2, '0')}`;
@@ -23,6 +24,7 @@ export default function SearchResults() {
const currentTrack = usePlayerStore(s => s.currentTrack); const currentTrack = usePlayerStore(s => s.currentTrack);
const psyDrag = useDragDrop(); const psyDrag = useDragDrop();
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const showBitrate = useThemeStore(s => s.showBitrate);
useEffect(() => { useEffect(() => {
if (!query.trim()) { setResults(null); return; } if (!query.trim()) { setResults(null); return; }
@@ -116,7 +118,7 @@ export default function SearchResults() {
<div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div> <div className="track-artist-cell"><span className="track-artist" title={song.artist}>{song.artist}</span></div>
<div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div> <div className="track-artist-cell"><span className="track-artist" title={song.album}>{song.album}</span></div>
<span className="track-codec" style={{ alignSelf: 'center' }}> <span className="track-codec" style={{ alignSelf: 'center' }}>
{[song.suffix?.toUpperCase(), song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')} {[song.suffix?.toUpperCase(), showBitrate && song.bitRate ? `${song.bitRate} kbps` : ''].filter(Boolean).join(' · ')}
</span> </span>
<span className="track-duration" style={{ textAlign: 'right' }}> <span className="track-duration" style={{ textAlign: 'right' }}>
{formatDuration(song.duration)} {formatDuration(song.duration)}
+41
View File
@@ -1612,6 +1612,47 @@ export default function Settings() {
</div> </div>
</section> </section>
<section className="settings-section">
<div className="settings-section-header">
<Palette size={18} />
<h2>{t('settings.visualOptionsTitle')}</h2>
</div>
<div className="settings-card">
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.coverArtBackground')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.coverArtBackgroundSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.enableCoverArtBackground} onChange={e => theme.setEnableCoverArtBackground(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.playlistCoverPhoto')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.playlistCoverPhotoSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.enablePlaylistCoverPhoto} onChange={e => theme.setEnablePlaylistCoverPhoto(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500 }}>{t('settings.showBitrate')}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.showBitrateSub')}</div>
</div>
<label className="toggle-switch">
<input type="checkbox" checked={theme.showBitrate} onChange={e => theme.setShowBitrate(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
<section className="settings-section"> <section className="settings-section">
<div className="settings-section-header"> <div className="settings-section-header">
<ZoomIn size={18} /> <ZoomIn size={18} />
+36
View File
@@ -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<DeviceSyncJobState>()((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' }),
}));
+35 -19
View File
@@ -7,21 +7,14 @@ export interface DeviceSyncSource {
name: string; name: string;
} }
export interface DeviceSyncJob {
id: string;
total: number;
done: number;
skipped: number;
failed: number;
status: 'running' | 'done' | 'cancelled';
}
interface DeviceSyncState { interface DeviceSyncState {
targetDir: string | null; targetDir: string | null;
filenameTemplate: string; filenameTemplate: string;
sources: DeviceSyncSource[]; // persistent device content list sources: DeviceSyncSource[]; // persistent device content list
checkedIds: string[]; // currently checked for deletion (not persisted) checkedIds: string[]; // currently checked for bulk actions (not persisted)
activeJob: DeviceSyncJob | null; 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; setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void; setFilenameTemplate: (t: string) => void;
@@ -30,8 +23,12 @@ interface DeviceSyncState {
clearSources: () => void; clearSources: () => void;
toggleChecked: (id: string) => void; toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void; setCheckedIds: (ids: string[]) => void;
setActiveJob: (job: DeviceSyncJob | null) => void; markForDeletion: (ids: string[]) => void;
updateJob: (update: Partial<DeviceSyncJob>) => void; unmarkDeletion: (id: string) => void;
clearPendingDeletion: () => void;
removeSources: (ids: string[]) => void;
setDeviceFilePaths: (paths: string[]) => void;
setScanning: (v: boolean) => void;
} }
export const useDeviceSyncStore = create<DeviceSyncState>()( export const useDeviceSyncStore = create<DeviceSyncState>()(
@@ -41,7 +38,9 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
filenameTemplate: '{artist}/{album}/{track_number} - {title}', filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [], sources: [],
checkedIds: [], checkedIds: [],
activeJob: null, pendingDeletion: [],
deviceFilePaths: [],
scanning: false,
setTargetDir: (dir) => set({ targetDir: dir }), setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }), setFilenameTemplate: (t) => set({ filenameTemplate: t }),
@@ -57,9 +56,10 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
set((s) => ({ set((s) => ({
sources: s.sources.filter((x) => x.id !== id), sources: s.sources.filter((x) => x.id !== id),
checkedIds: s.checkedIds.filter((x) => x !== 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) => toggleChecked: (id) =>
set((s) => ({ set((s) => ({
@@ -70,12 +70,28 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
setCheckedIds: (ids) => set({ checkedIds: ids }), setCheckedIds: (ids) => set({ checkedIds: ids }),
setActiveJob: (job) => set({ activeJob: job }), markForDeletion: (ids) =>
updateJob: (update) =>
set((s) => ({ 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', name: 'psysonic_device_sync',
+1 -1
View File
@@ -57,7 +57,7 @@ export const useThemeStore = create<ThemeState>()(
setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }), setEnableCoverArtBackground: (v) => set({ enableCoverArtBackground: v }),
enablePlaylistCoverPhoto: true, enablePlaylistCoverPhoto: true,
setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }), setEnablePlaylistCoverPhoto: (v) => set({ enablePlaylistCoverPhoto: v }),
showBitrate: false, showBitrate: true,
setShowBitrate: (v) => set({ showBitrate: v }), setShowBitrate: (v) => set({ showBitrate: v }),
}), }),
{ {
+317 -31
View File
@@ -7573,24 +7573,51 @@ html.no-compositing .fs-seekbar-played {
.device-sync-header { .device-sync-header {
display: flex; display: flex;
align-items: center; flex-direction: column;
gap: 10px; gap: 16px;
margin-bottom: 10px; margin-bottom: 20px;
color: var(--text-primary);
flex-shrink: 0; 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-size: 1.3rem;
font-weight: 600; font-weight: 600;
margin: 0; margin: 0;
flex: 1;
} }
.device-sync-header-config { /* ── Config Row (Template + Drive Layout) ── */
.device-sync-config-row {
display: flex; display: flex;
align-items: center; flex-wrap: wrap;
gap: 8px; 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 { .device-sync-folder-path {
@@ -7606,28 +7633,104 @@ html.no-compositing .fs-seekbar-played {
border-radius: var(--radius-sm); 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; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
margin-bottom: 14px; }
.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; 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 { .device-sync-label-inline {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary); color: var(--text-secondary);
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; }
.device-sync-template-input-wrap {
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
} }
.device-sync-template-input { .device-sync-template-input {
flex: 1; width: 100%;
font-family: monospace; 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) ── */ /* ── 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: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-device-row.checked { background: var(--accent-dim); }
.device-sync-source-type { .device-sync-source-type {
@@ -7759,41 +7874,184 @@ html.no-compositing .fs-seekbar-played {
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Progress ── */ /* ── Status summary badges ── */
.device-sync-progress { .device-sync-status-summary {
display: flex; display: flex;
flex-direction: column; align-items: center;
gap: 8px; gap: 8px;
padding: 10px 14px; height: 52px;
border-top: 1px solid var(--border); padding: 0 14px;
border-bottom: 1px solid var(--border);
flex-shrink: 0; flex-shrink: 0;
} }
.device-sync-progress-bar-wrap { .device-sync-badge {
height: 4px; 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); background: var(--bg-input);
border-radius: 2px; border-radius: 2px;
overflow: hidden; overflow: hidden;
} }
.device-sync-progress-bar { .device-sync-bg-progress-bar {
height: 100%; height: 100%;
background: var(--accent); background: var(--accent);
border-radius: 2px; border-radius: 2px;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
.device-sync-progress-stats { .device-sync-bg-progress-text {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 6px;
font-size: 0.82rem; font-size: 0.78rem;
color: var(--text-secondary); 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); } .color-success { color: var(--success, #4ade80); }
/* ── Browser panel ── */ /* ── Browser panel ── */
@@ -7842,7 +8100,35 @@ html.no-compositing .fs-seekbar-played {
} }
.device-sync-search-wrap .input { .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 { .device-sync-list {
+7
View File
@@ -912,6 +912,13 @@
background: color-mix(in srgb, var(--accent) 20%, transparent); 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 { @keyframes spin-slow {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
+538
View File
@@ -14447,3 +14447,541 @@ input[type="range"]:hover::-webkit-slider-thumb {
--danger: #d55e00; --danger: #d55e00;
--volume-accent: #ffd700; --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;
}