mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-21 22:15:40 +00:00
feat(device-sync): overhaul Device Sync page
- New two-panel layout with live album search (search3, 300ms debounce) and 10 random albums when search is empty — replaces full paginated load - Pre-sync summary modal: shows files to add/delete, net change, available space; Proceed button disabled when space is insufficient - calculate_sync_payload now filters already-synced tracks (path exists on device) so add_bytes/add_count reflect the true delta - Space check accounts for pending deletions: addBytes > availableBytes + delBytes - Separate deviceSyncJobStore for ephemeral job progress state - Removable drive detection + auto-poll every 5 s via sysinfo - Desired-state diff: de-selecting a synced source stages it for deletion instead of silently removing it - Status badges (Synced / Pending / Deletion) with matching row highlights - Live-Search badge (⚡) and Zufallsalben section label (⇌) in album browser - Status summary row height aligned with search field (52 px) - i18n: all new keys added to all 8 locales (en/de/fr/nl/nb/zh/ru/es) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Generated
+12
@@ -3581,6 +3581,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"symphonia",
|
||||
"sysinfo",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -4927,6 +4928,17 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.33.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fc858248ea01b66f19d8e8a6d55f41deaf91e9d495246fd01368d99935c6c01"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
|
||||
@@ -45,6 +45,7 @@ discord-rich-presence = "0.2"
|
||||
url = "2"
|
||||
thread-priority = "1"
|
||||
lofty = "0.22"
|
||||
sysinfo = { version = "0.33", default-features = false, features = ["disk"] }
|
||||
id3 = "1.16.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
|
||||
+510
-14
@@ -1351,7 +1351,68 @@ async fn purge_hot_cache(custom_dir: Option<String>, app: tauri::AppHandle) -> R
|
||||
|
||||
// ─── Device Sync ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
/// Information about a single mounted removable drive.
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct RemovableDrive {
|
||||
name: String,
|
||||
mount_point: String,
|
||||
available_space: u64,
|
||||
total_space: u64,
|
||||
file_system: String,
|
||||
is_removable: bool,
|
||||
}
|
||||
|
||||
/// Returns all currently mounted removable drives.
|
||||
/// On Linux these are typically USB sticks / SD cards under /media or /run/media.
|
||||
/// On macOS they appear under /Volumes. On Windows they are separate drive letters.
|
||||
#[tauri::command]
|
||||
fn get_removable_drives() -> Vec<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 {
|
||||
id: String,
|
||||
url: String,
|
||||
@@ -1366,6 +1427,14 @@ struct TrackSyncInfo {
|
||||
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)]
|
||||
struct SyncTrackResult {
|
||||
path: String,
|
||||
@@ -1524,22 +1593,445 @@ async fn delete_device_file(path: String) -> Result<(), String> {
|
||||
let p = std::path::PathBuf::from(&path);
|
||||
if p.exists() {
|
||||
tokio::fs::remove_file(&p).await.map_err(|e| e.to_string())?;
|
||||
// Prune empty parent dirs (album → artist)
|
||||
let mut current = p.parent().map(|d| d.to_path_buf());
|
||||
for _ in 0..2 {
|
||||
let Some(dir) = current else { break };
|
||||
let is_empty = std::fs::read_dir(&dir)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = tokio::fs::remove_dir(&dir).await;
|
||||
current = dir.parent().map(|d| d.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
prune_empty_parents(&p, 2).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Prune empty parent directories up to `levels` levels above `file_path`.
|
||||
async fn prune_empty_parents(file_path: &std::path::Path, levels: usize) {
|
||||
let mut current = file_path.parent().map(|d| d.to_path_buf());
|
||||
for _ in 0..levels {
|
||||
let Some(dir) = current else { break };
|
||||
let is_empty = std::fs::read_dir(&dir)
|
||||
.map(|mut rd| rd.next().is_none())
|
||||
.unwrap_or(false);
|
||||
if is_empty {
|
||||
let _ = tokio::fs::remove_dir(&dir).await;
|
||||
current = dir.parent().map(|d| d.to_path_buf());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SubsonicAuthPayload {
|
||||
base_url: String,
|
||||
u: String,
|
||||
t: String,
|
||||
s: String,
|
||||
v: String,
|
||||
c: String,
|
||||
f: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct DeviceSyncSourcePayload {
|
||||
#[serde(rename = "type")]
|
||||
source_type: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SyncDeltaResult {
|
||||
add_bytes: u64,
|
||||
add_count: u32,
|
||||
del_bytes: u64,
|
||||
del_count: u32,
|
||||
available_bytes: u64,
|
||||
tracks: Vec<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.
|
||||
@@ -1884,6 +2376,7 @@ pub fn run() {
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
calculate_sync_payload,
|
||||
exit_app,
|
||||
set_window_decorations,
|
||||
no_compositing_mode,
|
||||
@@ -1932,9 +2425,12 @@ pub fn run() {
|
||||
delete_hot_cache_track,
|
||||
purge_hot_cache,
|
||||
sync_track_to_device,
|
||||
sync_batch_to_device,
|
||||
compute_sync_paths,
|
||||
list_device_dir_files,
|
||||
delete_device_file,
|
||||
delete_device_files,
|
||||
get_removable_drives,
|
||||
toggle_tray_icon,
|
||||
check_dir_accessible,
|
||||
download_zip,
|
||||
|
||||
+11
-4
@@ -23,7 +23,7 @@ function getAuthParams(username: string, password: string) {
|
||||
return { u: username, t: token, s: salt, v: '1.16.1', c: `psysonic/${version}`, f: 'json' };
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
export function getClient() {
|
||||
const { getBaseUrl, getActiveServer } = useAuthStore.getState();
|
||||
const server = getActiveServer();
|
||||
const baseUrl = getBaseUrl();
|
||||
@@ -741,11 +741,18 @@ export async function fetchStatisticsFormatSample(): Promise<StatisticsFormatSam
|
||||
}
|
||||
|
||||
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(),
|
||||
});
|
||||
const indices = data.artists?.index ?? [];
|
||||
return indices.flatMap(i => i.artist ?? []);
|
||||
const rawIdx = data.artists?.index;
|
||||
const indices = Array.isArray(rawIdx) ? rawIdx : (rawIdx ? [rawIdx] : []);
|
||||
const artists: SubsonicArtist[] = [];
|
||||
for (const idx of indices) {
|
||||
const rawArt = idx.artist;
|
||||
const arr = Array.isArray(rawArt) ? rawArt : (rawArt ? [rawArt] : []);
|
||||
artists.push(...arr);
|
||||
}
|
||||
return artists;
|
||||
}
|
||||
|
||||
export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createPortal } from 'react-dom';
|
||||
import { usePlayerStore } from '../store/playerStore';
|
||||
import { useOfflineStore } from '../store/offlineStore';
|
||||
import { useOfflineJobStore } from '../store/offlineJobStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useSidebarStore } from '../store/sidebarStore';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
@@ -50,6 +51,12 @@ export default function Sidebar({
|
||||
const offlineJobs = useOfflineJobStore(s => s.jobs);
|
||||
const cancelAllDownloads = useOfflineJobStore(s => s.cancelAllDownloads);
|
||||
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 serverId = useAuthStore(s => s.activeServerId ?? '');
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
@@ -369,6 +376,19 @@ export default function Sidebar({
|
||||
</button>
|
||||
</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>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ export const deTranslation = {
|
||||
expand: 'Sidebar einblenden',
|
||||
collapse: 'Sidebar ausblenden',
|
||||
downloadingTracks: '{{n}} Tracks werden gecacht…',
|
||||
syncingTracks: 'Synchronisiere {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download abbrechen',
|
||||
offlineLibrary: 'Offline-Bibliothek',
|
||||
genres: 'Genres',
|
||||
@@ -1026,8 +1027,15 @@ export const deTranslation = {
|
||||
title: 'Gerätesync',
|
||||
targetFolder: 'Zielordner',
|
||||
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…',
|
||||
filenameTemplate: 'Dateinamen-Vorlage',
|
||||
targetDevice: 'Zielgerät',
|
||||
templateHint: 'Variablen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'Auf dem Gerät',
|
||||
addSources: 'Hinzufügen…',
|
||||
@@ -1045,6 +1053,10 @@ export const deTranslation = {
|
||||
tabArtists: 'Künstler',
|
||||
searchPlaceholder: 'Suche…',
|
||||
syncButton: 'Auf Gerät übertragen',
|
||||
actionTransfer: 'Auf Gerät übertragen',
|
||||
actionDelete: 'Vom Gerät löschen',
|
||||
actionApplyAll: 'Änderungen synchronisieren',
|
||||
templatePreview: 'Vorschau',
|
||||
cancel: 'Abbrechen',
|
||||
noTargetDir: 'Bitte zuerst einen Zielordner auswählen.',
|
||||
noSources: 'Bitte mindestens eine Quelle auswählen.',
|
||||
@@ -1052,5 +1064,25 @@ export const deTranslation = {
|
||||
fetchError: 'Fehler beim Laden der Tracks vom Server.',
|
||||
syncComplete: 'Übertragung abgeschlossen.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
+35
-2
@@ -18,6 +18,7 @@ export const enTranslation = {
|
||||
collapse: 'Collapse Sidebar',
|
||||
|
||||
downloadingTracks: 'Caching {{n}} tracks…',
|
||||
syncingTracks: 'Syncing {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancel download',
|
||||
offlineLibrary: 'Offline Library',
|
||||
genres: 'Genres',
|
||||
@@ -1028,15 +1029,23 @@ export const enTranslation = {
|
||||
title: 'Device Sync',
|
||||
targetFolder: 'Target Folder',
|
||||
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…',
|
||||
filenameTemplate: 'Filename Template',
|
||||
targetDevice: 'Target Device',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
onDevice: 'On Device',
|
||||
onDevice: 'Device Manager',
|
||||
addSources: 'Add…',
|
||||
colName: 'Name',
|
||||
colType: 'Type',
|
||||
colStatus: 'Status',
|
||||
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}}?',
|
||||
deleteComplete: '{{count}} item(s) removed from device.',
|
||||
selectedSources: 'Selected Sources',
|
||||
@@ -1047,6 +1056,10 @@ export const enTranslation = {
|
||||
tabArtists: 'Artists',
|
||||
searchPlaceholder: 'Search…',
|
||||
syncButton: 'Sync to Device',
|
||||
actionTransfer: 'Transfer to Device',
|
||||
actionDelete: 'Delete from Device',
|
||||
actionApplyAll: 'Apply All Changes',
|
||||
templatePreview: 'Preview',
|
||||
cancel: 'Cancel',
|
||||
noTargetDir: 'Please choose a target folder first.',
|
||||
noSources: 'Please select at least one source.',
|
||||
@@ -1054,5 +1067,25 @@ export const enTranslation = {
|
||||
fetchError: 'Failed to fetch tracks from server.',
|
||||
syncComplete: 'Sync complete.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const esTranslation = {
|
||||
collapse: 'Colapsar Barra Lateral',
|
||||
|
||||
downloadingTracks: 'Descargando {{n}} pistas…',
|
||||
syncingTracks: 'Sincronizando {{done}}/{{total}}…',
|
||||
cancelDownload: 'Cancelar descarga',
|
||||
offlineLibrary: 'Biblioteca Offline',
|
||||
genres: 'Géneros',
|
||||
@@ -1029,8 +1030,15 @@ export const esTranslation = {
|
||||
title: 'Sincronizar dispositivo',
|
||||
targetFolder: 'Carpeta de destino',
|
||||
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…',
|
||||
filenameTemplate: 'Plantilla de nombre de archivo',
|
||||
targetDevice: 'Dispositivo de destino',
|
||||
templateHint: 'Variables: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Eliminar archivos fuera de la selección',
|
||||
cleanupNothingToDelete: 'Nada que eliminar — el dispositivo ya está sincronizado.',
|
||||
@@ -1044,6 +1052,10 @@ export const esTranslation = {
|
||||
tabArtists: 'Artistas',
|
||||
searchPlaceholder: 'Buscar…',
|
||||
syncButton: 'Sincronizar al dispositivo',
|
||||
actionTransfer: 'Transferir al dispositivo',
|
||||
actionDelete: 'Eliminar del dispositivo',
|
||||
actionApplyAll: 'Aplicar todos los cambios',
|
||||
templatePreview: 'Vista previa',
|
||||
cancel: 'Cancelar',
|
||||
noTargetDir: 'Por favor, elige primero una carpeta de destino.',
|
||||
noSources: 'Por favor, selecciona al menos una fuente.',
|
||||
@@ -1051,5 +1063,33 @@ export const esTranslation = {
|
||||
fetchError: 'Error al obtener pistas del servidor.',
|
||||
syncComplete: 'Sincronización completada.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const frTranslation = {
|
||||
expand: 'Développer la barre latérale',
|
||||
collapse: 'Réduire la barre latérale',
|
||||
downloadingTracks: '{{n}} pistes en cache…',
|
||||
syncingTracks: 'Synchro {{done}}/{{total}}…',
|
||||
cancelDownload: 'Annuler le téléchargement',
|
||||
offlineLibrary: 'Bibliothèque hors ligne',
|
||||
genres: 'Genres',
|
||||
@@ -1024,8 +1025,15 @@ export const frTranslation = {
|
||||
title: 'Sync appareil',
|
||||
targetFolder: 'Dossier cible',
|
||||
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…',
|
||||
filenameTemplate: 'Modèle de nom de fichier',
|
||||
targetDevice: 'Appareil cible',
|
||||
templateHint: 'Variables : {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Supprimer les fichiers absents',
|
||||
cleanupNothingToDelete: 'Rien à supprimer — l\'appareil est déjà synchronisé.',
|
||||
@@ -1039,6 +1047,10 @@ export const frTranslation = {
|
||||
tabArtists: 'Artistes',
|
||||
searchPlaceholder: 'Rechercher…',
|
||||
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',
|
||||
noTargetDir: 'Veuillez d\'abord choisir un dossier cible.',
|
||||
noSources: 'Veuillez sélectionner au moins une source.',
|
||||
@@ -1046,5 +1058,33 @@ export const frTranslation = {
|
||||
fetchError: 'Échec du chargement des pistes depuis le serveur.',
|
||||
syncComplete: 'Synchronisation terminée.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nbTranslation = {
|
||||
expand: 'Utvid sidefelt',
|
||||
collapse: 'Skjul sidefelt',
|
||||
downloadingTracks: 'Bufre {{n}} spor…',
|
||||
syncingTracks: 'Synkroniserer {{done}}/{{total}}…',
|
||||
cancelDownload: 'Avbryt nedlasting',
|
||||
offlineLibrary: 'Frakoblet bibliotek',
|
||||
genres: 'Sjangere',
|
||||
@@ -1023,8 +1024,15 @@ export const nbTranslation = {
|
||||
title: 'Enhetssynk',
|
||||
targetFolder: 'Målmappe',
|
||||
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…',
|
||||
filenameTemplate: 'Filnavnmal',
|
||||
targetDevice: 'Målenhet',
|
||||
templateHint: 'Variabler: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Fjern filer som ikke er i utvalget',
|
||||
cleanupNothingToDelete: 'Ingenting å fjerne — enheten er allerede synkronisert.',
|
||||
@@ -1038,6 +1046,10 @@ export const nbTranslation = {
|
||||
tabArtists: 'Artister',
|
||||
searchPlaceholder: 'Søk…',
|
||||
syncButton: 'Synkroniser til enhet',
|
||||
actionTransfer: 'Overfør til enhet',
|
||||
actionDelete: 'Slett fra enhet',
|
||||
actionApplyAll: 'Bruk alle endringer',
|
||||
templatePreview: 'Forhåndsvisning',
|
||||
cancel: 'Avbryt',
|
||||
noTargetDir: 'Velg en målmappe først.',
|
||||
noSources: 'Velg minst én kilde.',
|
||||
@@ -1045,5 +1057,33 @@ export const nbTranslation = {
|
||||
fetchError: 'Kunne ikke hente spor fra serveren.',
|
||||
syncComplete: 'Synkronisering fullført.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const nlTranslation = {
|
||||
expand: 'Zijbalk uitklappen',
|
||||
collapse: 'Zijbalk inklappen',
|
||||
downloadingTracks: '{{n}} nummers worden gecached…',
|
||||
syncingTracks: 'Synchroniseren {{done}}/{{total}}…',
|
||||
cancelDownload: 'Download annuleren',
|
||||
offlineLibrary: 'Offline bibliotheek',
|
||||
genres: 'Genres',
|
||||
@@ -1021,8 +1022,15 @@ export const nlTranslation = {
|
||||
title: 'Apparaatsync',
|
||||
targetFolder: 'Doelmap',
|
||||
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…',
|
||||
filenameTemplate: 'Bestandsnaamsjabloon',
|
||||
targetDevice: 'Doelapparaat',
|
||||
templateHint: 'Variabelen: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Niet-geselecteerde bestanden verwijderen',
|
||||
cleanupNothingToDelete: 'Niets te verwijderen — apparaat is al gesynchroniseerd.',
|
||||
@@ -1036,6 +1044,10 @@ export const nlTranslation = {
|
||||
tabArtists: 'Artiesten',
|
||||
searchPlaceholder: 'Zoeken…',
|
||||
syncButton: 'Synchroniseren naar apparaat',
|
||||
actionTransfer: 'Overdragen naar apparaat',
|
||||
actionDelete: 'Verwijderen van apparaat',
|
||||
actionApplyAll: 'Alle wijzigingen toepassen',
|
||||
templatePreview: 'Voorbeeld',
|
||||
cancel: 'Annuleren',
|
||||
noTargetDir: 'Kies eerst een doelmap.',
|
||||
noSources: 'Selecteer minimaal één bron.',
|
||||
@@ -1043,5 +1055,33 @@ export const nlTranslation = {
|
||||
fetchError: 'Ophalen van nummers van de server mislukt.',
|
||||
syncComplete: 'Synchronisatie voltooid.',
|
||||
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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export const ruTranslation = {
|
||||
expand: 'Развернуть боковую панель',
|
||||
collapse: 'Свернуть боковую панель',
|
||||
downloadingTracks: 'Кэширование {{n}} треков…',
|
||||
syncingTracks: 'Синхронизация {{done}}/{{total}}…',
|
||||
cancelDownload: 'Отменить загрузку',
|
||||
offlineLibrary: 'Офлайн-библиотека',
|
||||
genres: 'Жанры',
|
||||
@@ -1082,8 +1083,15 @@ export const ruTranslation = {
|
||||
title: 'Синхронизация устройства',
|
||||
targetFolder: 'Папка назначения',
|
||||
noFolderChosen: 'Папка не выбрана',
|
||||
selectDrive: 'Выберите диск…',
|
||||
refreshDrives: 'Обновить диски',
|
||||
noDrivesDetected: 'Съёмные диски не обнаружены',
|
||||
browseManual: 'Обзор вручную…',
|
||||
free: 'свободно',
|
||||
notMountedVolume: 'Цель не находится на смонтированном томе. Диск мог быть отключён.',
|
||||
chooseFolder: 'Выбрать…',
|
||||
filenameTemplate: 'Шаблон имени файла',
|
||||
targetDevice: 'Целевое устройство',
|
||||
templateHint: 'Переменные: {artist}, {album}, {title}, {track_number}, {disc_number}, {year}',
|
||||
cleanupButton: 'Удалить файлы вне выборки',
|
||||
cleanupNothingToDelete: 'Нечего удалять — устройство уже синхронизировано.',
|
||||
@@ -1097,6 +1105,10 @@ export const ruTranslation = {
|
||||
tabArtists: 'Исполнители',
|
||||
searchPlaceholder: 'Поиск…',
|
||||
syncButton: 'Синхронизировать на устройство',
|
||||
actionTransfer: 'Перенести на устройство',
|
||||
actionDelete: 'Удалить с устройства',
|
||||
actionApplyAll: 'Применить все изменения',
|
||||
templatePreview: 'Предпросмотр',
|
||||
cancel: 'Отмена',
|
||||
noTargetDir: 'Сначала выберите папку назначения.',
|
||||
noSources: 'Выберите хотя бы один источник.',
|
||||
@@ -1104,5 +1116,33 @@ export const ruTranslation = {
|
||||
fetchError: 'Ошибка загрузки треков с сервера.',
|
||||
syncComplete: 'Синхронизация завершена.',
|
||||
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: 'Случайные альбомы',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zhTranslation = {
|
||||
expand: '展开侧边栏',
|
||||
collapse: '收起侧边栏',
|
||||
downloadingTracks: '正在缓存 {{n}} 首歌曲…',
|
||||
syncingTracks: '同步中 {{done}}/{{total}}…',
|
||||
cancelDownload: '取消下载',
|
||||
offlineLibrary: '离线音乐库',
|
||||
genres: '流派',
|
||||
@@ -1017,8 +1018,15 @@ export const zhTranslation = {
|
||||
title: '设备同步',
|
||||
targetFolder: '目标文件夹',
|
||||
noFolderChosen: '未选择文件夹',
|
||||
selectDrive: '选择驱动器…',
|
||||
refreshDrives: '刷新驱动器',
|
||||
noDrivesDetected: '未检测到可移动驱动器',
|
||||
browseManual: '手动浏览…',
|
||||
free: '可用',
|
||||
notMountedVolume: '目标不在已挂载的卷上。驱动器可能已断开。',
|
||||
chooseFolder: '选择…',
|
||||
filenameTemplate: '文件名模板',
|
||||
targetDevice: '目标设备',
|
||||
templateHint: '变量:{artist}、{album}、{title}、{track_number}、{disc_number}、{year}',
|
||||
cleanupButton: '删除不在选择范围内的文件',
|
||||
cleanupNothingToDelete: '无需删除 — 设备已同步。',
|
||||
@@ -1032,6 +1040,10 @@ export const zhTranslation = {
|
||||
tabArtists: '艺术家',
|
||||
searchPlaceholder: '搜索…',
|
||||
syncButton: '同步到设备',
|
||||
actionTransfer: '传输到设备',
|
||||
actionDelete: '从设备删除',
|
||||
actionApplyAll: '应用所有更改',
|
||||
templatePreview: '预览',
|
||||
cancel: '取消',
|
||||
noTargetDir: '请先选择目标文件夹。',
|
||||
noSources: '请至少选择一个来源。',
|
||||
@@ -1039,5 +1051,33 @@ export const zhTranslation = {
|
||||
fetchError: '从服务器加载曲目失败。',
|
||||
syncComplete: '同步完成。',
|
||||
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
File diff suppressed because it is too large
Load Diff
@@ -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' }),
|
||||
}));
|
||||
@@ -7,21 +7,14 @@ export interface DeviceSyncSource {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeviceSyncJob {
|
||||
id: string;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
status: 'running' | 'done' | 'cancelled';
|
||||
}
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
filenameTemplate: string;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for deletion (not persisted)
|
||||
activeJob: DeviceSyncJob | null;
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
setFilenameTemplate: (t: string) => void;
|
||||
@@ -30,8 +23,12 @@ interface DeviceSyncState {
|
||||
clearSources: () => void;
|
||||
toggleChecked: (id: string) => void;
|
||||
setCheckedIds: (ids: string[]) => void;
|
||||
setActiveJob: (job: DeviceSyncJob | null) => void;
|
||||
updateJob: (update: Partial<DeviceSyncJob>) => void;
|
||||
markForDeletion: (ids: string[]) => void;
|
||||
unmarkDeletion: (id: string) => void;
|
||||
clearPendingDeletion: () => void;
|
||||
removeSources: (ids: string[]) => void;
|
||||
setDeviceFilePaths: (paths: string[]) => void;
|
||||
setScanning: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
@@ -41,7 +38,9 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
activeJob: null,
|
||||
pendingDeletion: [],
|
||||
deviceFilePaths: [],
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
|
||||
@@ -57,9 +56,10 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => x.id !== id),
|
||||
checkedIds: s.checkedIds.filter((x) => x !== id),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearSources: () => set({ sources: [], checkedIds: [] }),
|
||||
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
|
||||
|
||||
toggleChecked: (id) =>
|
||||
set((s) => ({
|
||||
@@ -70,12 +70,28 @@ export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
|
||||
setCheckedIds: (ids) => set({ checkedIds: ids }),
|
||||
|
||||
setActiveJob: (job) => set({ activeJob: job }),
|
||||
|
||||
updateJob: (update) =>
|
||||
markForDeletion: (ids) =>
|
||||
set((s) => ({
|
||||
activeJob: s.activeJob ? { ...s.activeJob, ...update } : null,
|
||||
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
unmarkDeletion: (id) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearPendingDeletion: () => set({ pendingDeletion: [] }),
|
||||
|
||||
removeSources: (ids) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => !ids.includes(x.id)),
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
|
||||
setScanning: (v) => set({ scanning: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_device_sync',
|
||||
|
||||
+317
-31
@@ -7573,24 +7573,51 @@ html.no-compositing .fs-seekbar-played {
|
||||
|
||||
.device-sync-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-primary);
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-header h1 {
|
||||
.device-sync-header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.device-sync-header-title h1 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-header-config {
|
||||
/* ── Config Row (Template + Drive Layout) ── */
|
||||
|
||||
.device-sync-config-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 30px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.device-sync-template-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.device-sync-target-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-end; /* Flush right alignment */
|
||||
flex: 1; /* allow block expansion */
|
||||
}
|
||||
|
||||
.device-sync-folder-path {
|
||||
@@ -7606,28 +7633,104 @@ html.no-compositing .fs-seekbar-played {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Template row ── */
|
||||
/* ── Drive layout ── */
|
||||
|
||||
.device-sync-template-row {
|
||||
.device-sync-drive-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.device-sync-drive-meta {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-align: right;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.device-sync-drive-icon {
|
||||
color: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-drive-select {
|
||||
min-width: 200px;
|
||||
max-width: 320px;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.device-sync-drive-controls .btn-ghost {
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
padding: 0 !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-no-drives {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--warning, #f59e0b);
|
||||
padding: 5px 10px;
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 10%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.device-sync-label-inline {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-template-input-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.device-sync-template-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
font-size: 0.95rem;
|
||||
height: 40px;
|
||||
padding: 0 10px;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.device-sync-template-preview {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
padding-left: 2px;
|
||||
opacity: 0.75;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Main area (device panel + browser) ── */
|
||||
@@ -7746,7 +7849,19 @@ html.no-compositing .fs-seekbar-played {
|
||||
}
|
||||
|
||||
.device-sync-device-row:last-child { border-bottom: none; }
|
||||
.device-sync-device-row:hover { background: var(--bg-hover); }
|
||||
.device-sync-source-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.device-sync-source-row.deletion, .device-sync-row.deletion {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.device-sync-row-name {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-device-row.checked { background: var(--accent-dim); }
|
||||
|
||||
.device-sync-source-type {
|
||||
@@ -7759,41 +7874,184 @@ html.no-compositing .fs-seekbar-played {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Progress ── */
|
||||
/* ── Status summary badges ── */
|
||||
|
||||
.device-sync-progress {
|
||||
.device-sync-status-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
height: 52px;
|
||||
padding: 0 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-progress-bar-wrap {
|
||||
height: 4px;
|
||||
.device-sync-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.device-sync-badge.synced {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 15%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
.device-sync-badge.pending {
|
||||
background: color-mix(in srgb, var(--warning, #f59e0b) 15%, transparent);
|
||||
color: var(--warning, #f59e0b);
|
||||
}
|
||||
|
||||
.device-sync-badge.deletion {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Status column ── */
|
||||
|
||||
.device-sync-list-col-status {
|
||||
width: 50px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex-shrink: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.device-sync-list-col-actions {
|
||||
width: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Status icons per row ── */
|
||||
|
||||
.device-sync-status-icon {
|
||||
width: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-status-icon.synced { color: var(--success, #4ade80); }
|
||||
.device-sync-status-icon.pending { color: var(--warning, #f59e0b); }
|
||||
.device-sync-status-icon.deletion { color: var(--danger, #f38ba8); }
|
||||
|
||||
/* ── Per-row action buttons ── */
|
||||
|
||||
.device-sync-row-actions {
|
||||
width: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-sync-action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, transform 0.1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.device-sync-device-row:hover .device-sync-action-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.device-sync-action-btn.danger {
|
||||
color: var(--danger, #f38ba8);
|
||||
}
|
||||
.device-sync-action-btn.danger:hover {
|
||||
background: color-mix(in srgb, var(--danger, #f38ba8) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.device-sync-action-btn.muted:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.device-sync-action-btn.undo {
|
||||
color: var(--accent);
|
||||
opacity: 1;
|
||||
}
|
||||
.device-sync-action-btn.undo:hover {
|
||||
background: color-mix(in srgb, var(--accent) 15%, transparent);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* ── Row status variants ── */
|
||||
|
||||
.device-sync-device-row.synced {}
|
||||
.device-sync-device-row.pending {}
|
||||
.device-sync-device-row.deletion {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.device-sync-device-row.deletion .device-sync-row-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--danger, #f38ba8);
|
||||
}
|
||||
|
||||
/* ── Background sync progress (non-blocking) ── */
|
||||
|
||||
.device-sync-bg-progress {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 14px;
|
||||
border-top: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress.done {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 4%, transparent);
|
||||
}
|
||||
|
||||
.device-sync-bg-progress-bar-wrap {
|
||||
height: 3px;
|
||||
background: var(--bg-input);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.device-sync-progress-bar {
|
||||
.device-sync-bg-progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.device-sync-progress-stats {
|
||||
.device-sync-bg-progress-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.82rem;
|
||||
gap: 6px;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.device-sync-stat-muted { color: var(--text-secondary); display: flex; align-items: center; gap: 3px; }
|
||||
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
|
||||
.device-sync-stat-error { color: var(--danger); display: flex; align-items: center; gap: 3px; }
|
||||
.color-success { color: var(--success, #4ade80); }
|
||||
|
||||
/* ── Browser panel ── */
|
||||
@@ -7842,7 +8100,35 @@ html.no-compositing .fs-seekbar-played {
|
||||
}
|
||||
|
||||
.device-sync-search-wrap .input {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.device-sync-live-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 8px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--accent);
|
||||
background: var(--accent-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.device-sync-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 10px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.device-sync-list {
|
||||
|
||||
@@ -912,6 +912,13 @@
|
||||
background: color-mix(in srgb, var(--accent) 20%, transparent);
|
||||
}
|
||||
|
||||
/* ─── Sidebar device-sync queue ─── */
|
||||
.sidebar-sync-queue {
|
||||
background: color-mix(in srgb, var(--success, #4ade80) 10%, var(--bg-sidebar));
|
||||
border-color: color-mix(in srgb, var(--success, #4ade80) 25%, transparent);
|
||||
color: var(--success, #4ade80);
|
||||
}
|
||||
|
||||
@keyframes spin-slow {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user