feat(locallibrary): Settings > Local Library — folder management (Slice A)
Port the Tauri folder-management surface to a new Settings > Local Library sub-section (index 4); the browse-view gear routes here and pre-selects it. UI: - LocalLibrarySettings.slint: folder list (icon, display name, disabled/ unavailable badges, multi-select checkboxes) + toolbar (scan-all/add/edit/ remove icon buttons) + filter + Maintenance (cleanup missing) + two-step Danger Zone clear. ADR-008: plain bordered icon buttons + square badges. - LibFolderEditModal.slint: alias, enabled, network override + fs-type, change-path, accessibility status, last-scanned, scan-this-folder. Mounted at the AppShell root (overlays the window, ADR-009 by mount order). - SettingsView gains the section-4 sub-nav + panel; app.slint re-exports the new globals so the Rust layer can populate them. Controller (local_library_settings.rs): load/add/remove/edit/save/change-path, cleanup-missing (stats outside the DB lock), two-step clear, filter, select. All DB access via qbz-library through library_db::with_db on spawn_blocking; native dir picker + confirms via rfd. Selection preserved across reloads; network folders get an async 6s-timeout accessibility check (exists() fallback on timeout, mirroring Tauri). Core (qbz-library): network_fs_label() for fs-type parity; separator-safe delete_tracks_in_folder_prefixed() + remove_folder_with_tracks() (fixes the prefix-collision bug without changing the legacy fn). Scan engine lands in Slice B. cc
This commit is contained in:
11 files changed
+1527
-6
No files matched your search
@@ -1168,6 +1168,31 @@ impl LibraryDatabase {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all tracks under a folder, matching a path prefix terminated by
|
||||||
|
/// the separator so a sibling like `/music/jazz2` is NOT removed when
|
||||||
|
/// deleting `/music/jazz`. Use this for folder removal — the older
|
||||||
|
/// `delete_tracks_in_folder` (kept for backward behavior compatibility with
|
||||||
|
/// the Tauri command) has a prefix-collision bug (`{}%`, no separator).
|
||||||
|
pub fn delete_tracks_in_folder_prefixed(&self, folder: &str) -> Result<usize, LibraryError> {
|
||||||
|
let pattern = format!("{}/%", folder.trim_end_matches('/'));
|
||||||
|
let count = self
|
||||||
|
.conn
|
||||||
|
.execute(
|
||||||
|
"DELETE FROM local_tracks WHERE file_path LIKE ?",
|
||||||
|
params![pattern],
|
||||||
|
)
|
||||||
|
.map_err(|e| LibraryError::Database(e.to_string()))?;
|
||||||
|
Ok(count)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a folder and its indexed tracks (separator-safe cascade). Mirrors
|
||||||
|
/// the Tauri remove-folder command order: drop the folder row, then the
|
||||||
|
/// tracks under it. Returns the number of tracks removed.
|
||||||
|
pub fn remove_folder_with_tracks(&self, path: &str) -> Result<usize, LibraryError> {
|
||||||
|
self.remove_folder(path)?;
|
||||||
|
self.delete_tracks_in_folder_prefixed(path)
|
||||||
|
}
|
||||||
|
|
||||||
/// Clear all LOCAL library tracks (preserves Qobuz downloads)
|
/// Clear all LOCAL library tracks (preserves Qobuz downloads)
|
||||||
pub fn clear_all_tracks(&self) -> Result<(), LibraryError> {
|
pub fn clear_all_tracks(&self) -> Result<(), LibraryError> {
|
||||||
self.conn
|
self.conn
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pub use database::{
|
|||||||
pub use errors::LibraryError;
|
pub use errors::LibraryError;
|
||||||
pub use metadata::MetadataExtractor;
|
pub use metadata::MetadataExtractor;
|
||||||
pub use models::*;
|
pub use models::*;
|
||||||
pub use mount_info::is_network_path;
|
pub use mount_info::{is_network_path, network_fs_label};
|
||||||
pub use scanner::{LibraryScanner, ScanResult};
|
pub use scanner::{LibraryScanner, ScanResult};
|
||||||
pub use thumbnails::{
|
pub use thumbnails::{
|
||||||
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
|
clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size,
|
||||||
|
|||||||
@@ -81,6 +81,63 @@ pub fn is_network_path(_path: &Path) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Return the normalized network-filesystem label (`cifs` / `nfs` / `sshfs` /
|
||||||
|
/// `rclone` / `webdav` / `glusterfs` / `ceph` / `other`) for `path` when it
|
||||||
|
/// lives on a network-backed filesystem, else `None`. Mirrors the fs-type
|
||||||
|
/// classification the Tauri side persisted via `crate::network::is_network_path`,
|
||||||
|
/// so the Slint folder-settings modal can show + store the same auto-detected
|
||||||
|
/// type. (`is_network_path` returns only the bool; this adds the label.)
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn network_fs_label(path: &Path) -> Option<String> {
|
||||||
|
let mounts = read_mounts();
|
||||||
|
if mounts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let target = path
|
||||||
|
.canonicalize()
|
||||||
|
.unwrap_or_else(|_| path.to_path_buf());
|
||||||
|
let target_str = target.to_string_lossy();
|
||||||
|
|
||||||
|
let mut best: Option<(&str, usize)> = None;
|
||||||
|
for (mount_point, fs_type) in &mounts {
|
||||||
|
if target_str.starts_with(mount_point) {
|
||||||
|
let len = mount_point.len();
|
||||||
|
if best.map(|(_, l)| l < len).unwrap_or(true) {
|
||||||
|
best = Some((fs_type.as_str(), len));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fs_type = best.map(|(t, _)| t)?;
|
||||||
|
if !is_network_fs(fs_type) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(normalize_network_label(fs_type))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "linux"))]
|
||||||
|
pub fn network_fs_label(_path: &Path) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse the raw /proc/mounts fs type to the label set the folder-settings
|
||||||
|
/// modal exposes. Unknown network types fall through to `other`.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
fn normalize_network_label(fs_type: &str) -> String {
|
||||||
|
let lower = fs_type.to_lowercase();
|
||||||
|
let base = lower.strip_prefix("fuse.").unwrap_or(&lower);
|
||||||
|
match base {
|
||||||
|
"nfs" | "nfs4" => "nfs",
|
||||||
|
"cifs" | "smb" | "smbfs" | "smb3" => "cifs",
|
||||||
|
"sshfs" => "sshfs",
|
||||||
|
"rclone" | "rclonefs" => "rclone",
|
||||||
|
"davfs" | "webdav" => "webdav",
|
||||||
|
"glusterfs" => "glusterfs",
|
||||||
|
"ceph" => "ceph",
|
||||||
|
_ => "other",
|
||||||
|
}
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
fn read_mounts() -> Vec<(String, String)> {
|
fn read_mounts() -> Vec<(String, String)> {
|
||||||
// Inside Flatpak the sandbox's own /proc/mounts reflects the
|
// Inside Flatpak the sandbox's own /proc/mounts reflects the
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
//! Settings > Local Library controller (Slint).
|
||||||
|
//!
|
||||||
|
//! Hosts the folder-management surface that Tauri renders inline in the
|
||||||
|
//! browse view's gear panel: the folder list (add / remove / edit / enable /
|
||||||
|
//! alias / network override), maintenance (cleanup missing files), and the
|
||||||
|
//! two-step danger-zone clear. The scan engine + progress live in Slice B.
|
||||||
|
//!
|
||||||
|
//! All DB access goes through the frontend-agnostic `qbz_library` crate via
|
||||||
|
//! `crate::library_db::with_db(|db| …)` on `spawn_blocking` (rusqlite is
|
||||||
|
//! blocking). The authoritative full folder set (with per-row selection) is
|
||||||
|
//! kept in a module static; the Slint `LibraryFoldersState.folders` model is
|
||||||
|
//! the filtered render set derived from it.
|
||||||
|
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::{LazyLock, Mutex};
|
||||||
|
|
||||||
|
use slint::{ComponentHandle, ModelRc, VecModel, Weak};
|
||||||
|
|
||||||
|
use crate::{AppWindow, LibFolderEditState, LibraryFolderItem, LibraryFoldersState};
|
||||||
|
|
||||||
|
/// One registered folder + its UI selection state. The authoritative copy;
|
||||||
|
/// the Slint model is derived (filtered) from this.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FolderData {
|
||||||
|
id: i64,
|
||||||
|
path: String,
|
||||||
|
alias: Option<String>,
|
||||||
|
enabled: bool,
|
||||||
|
is_network: bool,
|
||||||
|
network_fs_type: Option<String>,
|
||||||
|
user_override_network: bool,
|
||||||
|
last_scan: Option<i64>,
|
||||||
|
accessible: bool,
|
||||||
|
selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
static FOLDERS: LazyLock<Mutex<Vec<FolderData>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||||
|
/// Bumped on every (re)load so a stale in-flight load is dropped on apply.
|
||||||
|
static FOLDERS_GEN: AtomicU64 = AtomicU64::new(0);
|
||||||
|
|
||||||
|
fn folders_lock() -> std::sync::MutexGuard<'static, Vec<FolderData>> {
|
||||||
|
FOLDERS.lock().unwrap_or_else(|e| e.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_name(f: &FolderData) -> String {
|
||||||
|
match f.alias.as_deref() {
|
||||||
|
Some(a) if !a.is_empty() => a.to_string(),
|
||||||
|
_ => f.path.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a folder's `last_scan` (unix seconds, 0/None = never) for display.
|
||||||
|
fn last_scan_label(ts: Option<i64>) -> String {
|
||||||
|
match ts {
|
||||||
|
None | Some(0) => "Never".to_string(),
|
||||||
|
Some(secs) => chrono::DateTime::from_timestamp(secs, 0)
|
||||||
|
.map(|dt| {
|
||||||
|
dt.with_timezone(&chrono::Local)
|
||||||
|
.format("%Y-%m-%d %H:%M")
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "Never".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// fs-type label -> the modal's QbzSelect index (0=auto,1 cifs … 8 other).
|
||||||
|
fn fs_label_to_index(label: Option<&str>) -> i32 {
|
||||||
|
match label.unwrap_or("") {
|
||||||
|
"cifs" => 1,
|
||||||
|
"nfs" => 2,
|
||||||
|
"sshfs" => 3,
|
||||||
|
"rclone" => 4,
|
||||||
|
"webdav" => 5,
|
||||||
|
"glusterfs" => 6,
|
||||||
|
"ceph" => 7,
|
||||||
|
"other" => 8,
|
||||||
|
_ => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_item(f: &FolderData) -> LibraryFolderItem {
|
||||||
|
LibraryFolderItem {
|
||||||
|
id: f.id as i32,
|
||||||
|
path: f.path.clone().into(),
|
||||||
|
alias: f.alias.clone().unwrap_or_default().into(),
|
||||||
|
display_name: display_name(f).into(),
|
||||||
|
enabled: f.enabled,
|
||||||
|
is_network: f.is_network,
|
||||||
|
network_fs_type: f.network_fs_type.clone().unwrap_or_default().into(),
|
||||||
|
user_override_network: f.user_override_network,
|
||||||
|
last_scan: f.last_scan.unwrap_or(0) as i32,
|
||||||
|
last_scan_label: last_scan_label(f.last_scan).into(),
|
||||||
|
accessible: f.accessible,
|
||||||
|
selected: f.selected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derive the filtered render model + selected-count from the static set.
|
||||||
|
fn derive(window: &AppWindow) {
|
||||||
|
let s = window.global::<LibraryFoldersState>();
|
||||||
|
let filter = s.get_filter().to_lowercase();
|
||||||
|
let q = filter.trim();
|
||||||
|
let guard = folders_lock();
|
||||||
|
let items: Vec<LibraryFolderItem> = guard
|
||||||
|
.iter()
|
||||||
|
.filter(|f| {
|
||||||
|
q.is_empty()
|
||||||
|
|| display_name(f).to_lowercase().contains(q)
|
||||||
|
|| f.path.to_lowercase().contains(q)
|
||||||
|
})
|
||||||
|
.map(to_item)
|
||||||
|
.collect();
|
||||||
|
let selected = guard.iter().filter(|f| f.selected).count() as i32;
|
||||||
|
drop(guard);
|
||||||
|
s.set_folders(ModelRc::new(VecModel::from(items)));
|
||||||
|
s.set_selected_count(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (Re)load the folder list. Pure read of the core `get_folders_with_metadata`
|
||||||
|
/// (the network re-detect + write the Tauri command does lives only in that
|
||||||
|
/// command, not the core fn — so this has no side effects). Selection is
|
||||||
|
/// preserved by id across the reload. Network folders get an async
|
||||||
|
/// accessibility check.
|
||||||
|
pub fn load_folders(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let gen = FOLDERS_GEN.fetch_add(1, Ordering::SeqCst) + 1;
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibraryFoldersState>().set_loading(true);
|
||||||
|
});
|
||||||
|
let weak2 = weak.clone();
|
||||||
|
let check_handle = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let rows = tokio::task::spawn_blocking(|| {
|
||||||
|
crate::library_db::with_db(|db| db.get_folders_with_metadata())
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let Some(rows) = rows else {
|
||||||
|
let _ = weak2.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibraryFoldersState>().set_loading(false);
|
||||||
|
});
|
||||||
|
crate::toast::error_weak(&weak2, "Couldn't load library folders");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Preserve selection across reloads.
|
||||||
|
let prev_sel: std::collections::HashSet<i64> =
|
||||||
|
folders_lock().iter().filter(|f| f.selected).map(|f| f.id).collect();
|
||||||
|
|
||||||
|
let data: Vec<FolderData> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|f| FolderData {
|
||||||
|
selected: prev_sel.contains(&f.id),
|
||||||
|
accessible: true,
|
||||||
|
id: f.id,
|
||||||
|
path: f.path,
|
||||||
|
alias: f.alias,
|
||||||
|
enabled: f.enabled,
|
||||||
|
is_network: f.is_network,
|
||||||
|
network_fs_type: f.network_fs_type,
|
||||||
|
user_override_network: f.user_override_network,
|
||||||
|
last_scan: f.last_scan,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let network: Vec<(i64, String)> = data
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.is_network)
|
||||||
|
.map(|f| (f.id, f.path.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
*folders_lock() = data;
|
||||||
|
|
||||||
|
let _ = weak2.upgrade_in_event_loop(move |w| {
|
||||||
|
if FOLDERS_GEN.load(Ordering::SeqCst) != gen {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
w.global::<LibraryFoldersState>().set_loading(false);
|
||||||
|
derive(&w);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (id, path) in network {
|
||||||
|
check_accessible(weak2.clone(), check_handle.clone(), id, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update one folder's accessibility in the static + UI (and the open modal).
|
||||||
|
fn update_accessible(weak: &Weak<AppWindow>, id: i64, accessible: bool) {
|
||||||
|
{
|
||||||
|
let mut g = folders_lock();
|
||||||
|
if let Some(f) = g.iter_mut().find(|f| f.id == id) {
|
||||||
|
f.accessible = accessible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
derive(&w);
|
||||||
|
let es = w.global::<LibFolderEditState>();
|
||||||
|
if es.get_open() && es.get_folder_id() as i64 == id {
|
||||||
|
es.set_accessible(accessible);
|
||||||
|
es.set_checking_accessible(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check a (network) folder's accessibility. Mirrors Tauri: exists? then
|
||||||
|
/// read_dir under a 6s timeout; on timeout fall back to exists() so a
|
||||||
|
/// slow-but-mounted share isn't falsely flagged unavailable.
|
||||||
|
pub fn check_accessible(
|
||||||
|
weak: Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
id: i64,
|
||||||
|
path: String,
|
||||||
|
) {
|
||||||
|
handle.spawn(async move {
|
||||||
|
if !std::path::Path::new(&path).exists() {
|
||||||
|
update_accessible(&weak, id, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let p = path.clone();
|
||||||
|
let res = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_secs(6),
|
||||||
|
tokio::task::spawn_blocking(move || std::fs::read_dir(&p).is_ok()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let accessible = match res {
|
||||||
|
Ok(Ok(ok)) => ok,
|
||||||
|
Ok(Err(_)) => false,
|
||||||
|
Err(_) => std::path::Path::new(&path).exists(),
|
||||||
|
};
|
||||||
|
update_accessible(&weak, id, accessible);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a folder via the native directory picker, auto-detecting network type.
|
||||||
|
pub fn add_folder(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let Some(dir) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Select music folder")
|
||||||
|
.pick_folder()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let path = dir.path().to_string_lossy().to_string();
|
||||||
|
let p = path.clone();
|
||||||
|
let (added, is_net) = tokio::task::spawn_blocking(move || {
|
||||||
|
let pb = std::path::Path::new(&p);
|
||||||
|
let is_net = qbz_library::is_network_path(pb);
|
||||||
|
let fs = if is_net {
|
||||||
|
qbz_library::network_fs_label(pb)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let ok = crate::library_db::with_db(|db| {
|
||||||
|
db.add_folder_with_network_info(&p, is_net, fs.as_deref())
|
||||||
|
})
|
||||||
|
.is_some();
|
||||||
|
(ok, is_net)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or((false, false));
|
||||||
|
|
||||||
|
if !added {
|
||||||
|
crate::toast::error_weak(&weak, "Couldn't add folder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if is_net {
|
||||||
|
crate::toast::success_weak(&weak, "Network folder detected");
|
||||||
|
}
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk-remove the selected folders (with confirm + cascade track delete).
|
||||||
|
pub fn remove_folders(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let ids: Vec<i64> = folders_lock()
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.selected)
|
||||||
|
.map(|f| f.id)
|
||||||
|
.collect();
|
||||||
|
if ids.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let count = ids.len();
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let ok = rfd::AsyncMessageDialog::new()
|
||||||
|
.set_title("Remove folders")
|
||||||
|
.set_description(format!(
|
||||||
|
"Remove {count} selected folder(s)? Their indexed tracks will be removed. Your audio files are not deleted."
|
||||||
|
))
|
||||||
|
.set_buttons(rfd::MessageButtons::YesNo)
|
||||||
|
.show()
|
||||||
|
.await;
|
||||||
|
if ok != rfd::MessageDialogResult::Yes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let ids2 = ids.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
crate::library_db::with_db(|db| {
|
||||||
|
for id in &ids2 {
|
||||||
|
if let Some(f) = db.get_folder_by_id(*id)? {
|
||||||
|
db.remove_folder_with_tracks(&f.path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle one folder's selection (UI state in the static), then re-derive.
|
||||||
|
pub fn toggle_select(weak: Weak<AppWindow>, id: i64) {
|
||||||
|
{
|
||||||
|
let mut g = folders_lock();
|
||||||
|
if let Some(f) = g.iter_mut().find(|f| f.id == id) {
|
||||||
|
f.selected = !f.selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| derive(&w));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the folder-settings modal for `id` (or the single selected folder
|
||||||
|
/// when `id == 0`, as the toolbar Edit button passes).
|
||||||
|
pub fn edit_folder(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, id: i64) {
|
||||||
|
let f = {
|
||||||
|
let g = folders_lock();
|
||||||
|
if id > 0 {
|
||||||
|
g.iter().find(|f| f.id == id).cloned()
|
||||||
|
} else {
|
||||||
|
let sel: Vec<FolderData> = g.iter().filter(|f| f.selected).cloned().collect();
|
||||||
|
if sel.len() == 1 {
|
||||||
|
Some(sel[0].clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let Some(f) = f else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let is_network = f.is_network;
|
||||||
|
let fid = f.id;
|
||||||
|
let path = f.path.clone();
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
let es = w.global::<LibFolderEditState>();
|
||||||
|
es.set_folder_id(f.id as i32);
|
||||||
|
es.set_path(f.path.clone().into());
|
||||||
|
es.set_alias(f.alias.clone().unwrap_or_default().into());
|
||||||
|
es.set_enabled(f.enabled);
|
||||||
|
es.set_is_network(f.is_network);
|
||||||
|
es.set_user_override_network(f.user_override_network);
|
||||||
|
es.set_fs_type_index(fs_label_to_index(f.network_fs_type.as_deref()));
|
||||||
|
es.set_accessible(f.accessible);
|
||||||
|
es.set_checking_accessible(f.is_network);
|
||||||
|
es.set_last_scan_label(last_scan_label(f.last_scan).into());
|
||||||
|
es.set_open(true);
|
||||||
|
});
|
||||||
|
if is_network {
|
||||||
|
check_accessible(weak, handle, fid, path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist folder settings from the modal. fs-type "auto" re-detects (network
|
||||||
|
/// only); a non-auto label is stored verbatim.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn save_folder_settings(
|
||||||
|
weak: Weak<AppWindow>,
|
||||||
|
handle: tokio::runtime::Handle,
|
||||||
|
id: i64,
|
||||||
|
alias: String,
|
||||||
|
enabled: bool,
|
||||||
|
is_network: bool,
|
||||||
|
fs_type: String,
|
||||||
|
user_override: bool,
|
||||||
|
) {
|
||||||
|
let path = folders_lock()
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.id == id)
|
||||||
|
.map(|f| f.path.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let ok = tokio::task::spawn_blocking(move || {
|
||||||
|
let fs_opt: Option<String> = if !is_network {
|
||||||
|
None
|
||||||
|
} else if fs_type == "auto" {
|
||||||
|
qbz_library::network_fs_label(std::path::Path::new(&path))
|
||||||
|
} else {
|
||||||
|
Some(fs_type)
|
||||||
|
};
|
||||||
|
let alias_opt = if alias.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(alias)
|
||||||
|
};
|
||||||
|
crate::library_db::with_db(|db| {
|
||||||
|
db.update_folder_settings(
|
||||||
|
id,
|
||||||
|
alias_opt.as_deref(),
|
||||||
|
enabled,
|
||||||
|
is_network,
|
||||||
|
fs_opt.as_deref(),
|
||||||
|
user_override,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
crate::toast::error_weak(&weak, "Couldn't save folder settings");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibFolderEditState>().set_open(false);
|
||||||
|
});
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change a folder's path via the picker (resets its last_scan; rejects dups).
|
||||||
|
pub fn change_folder_path(weak: Weak<AppWindow>, handle: tokio::runtime::Handle, id: i64) {
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let Some(dir) = rfd::AsyncFileDialog::new()
|
||||||
|
.set_title("Select music folder")
|
||||||
|
.pick_folder()
|
||||||
|
.await
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let new_path = dir.path().to_string_lossy().to_string();
|
||||||
|
let np = new_path.clone();
|
||||||
|
let ok = tokio::task::spawn_blocking(move || {
|
||||||
|
crate::library_db::with_db(|db| db.update_folder_path(id, &np)).is_some()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
crate::toast::error_weak(&weak, "Couldn't change folder location (path may already exist)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let np2 = new_path.clone();
|
||||||
|
let _ = weak.upgrade_in_event_loop(move |w| {
|
||||||
|
let es = w.global::<LibFolderEditState>();
|
||||||
|
if es.get_folder_id() as i64 == id {
|
||||||
|
es.set_path(np2.into());
|
||||||
|
es.set_last_scan_label("Never".into());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove tracks whose files no longer exist. Reads paths, drops the DB lock,
|
||||||
|
/// stats outside the lock, then deletes in chunks (avoids the Tauri
|
||||||
|
/// under-lock stat stall). Inline status auto-clears after 3s.
|
||||||
|
pub fn cleanup_missing(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
{
|
||||||
|
// Re-entry guard.
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let s = w.global::<LibraryFoldersState>();
|
||||||
|
if s.get_cleaning_missing() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.set_cleaning_missing(true);
|
||||||
|
s.set_cleanup_status("Scanning track paths...".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let result = tokio::task::spawn_blocking(|| {
|
||||||
|
let paths = crate::library_db::with_db(|db| db.get_all_track_paths())?;
|
||||||
|
let checked = paths.len();
|
||||||
|
let missing: Vec<i64> = paths
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, p)| !std::path::Path::new(p).exists())
|
||||||
|
.map(|(id, _)| id)
|
||||||
|
.collect();
|
||||||
|
let mut removed = 0usize;
|
||||||
|
if !missing.is_empty() {
|
||||||
|
for chunk in missing.chunks(500) {
|
||||||
|
removed += crate::library_db::with_db(|db| db.delete_tracks_by_ids(chunk))
|
||||||
|
.unwrap_or(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((checked, removed))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten();
|
||||||
|
|
||||||
|
let (status, toast_ok) = match result {
|
||||||
|
Some((checked, removed)) if removed > 0 => {
|
||||||
|
(format!("Removed {removed} of {checked} tracks"), true)
|
||||||
|
}
|
||||||
|
Some((checked, _)) => (format!("Checked {checked} tracks - all OK"), true),
|
||||||
|
None => ("Cleanup failed".to_string(), false),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let s = w.global::<LibraryFoldersState>();
|
||||||
|
s.set_cleaning_missing(false);
|
||||||
|
s.set_cleanup_status(status.clone().into());
|
||||||
|
}
|
||||||
|
if toast_ok {
|
||||||
|
crate::toast::success_weak(&weak, status);
|
||||||
|
} else {
|
||||||
|
crate::toast::error_weak(&weak, "Couldn't clean up missing files");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-clear the inline status after 3s.
|
||||||
|
let weak_clear = weak.clone();
|
||||||
|
h.spawn(async move {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||||
|
let _ = weak_clear.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibraryFoldersState>().set_cleanup_status("".into());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two-step danger-zone clear of all indexed tracks (audio files untouched).
|
||||||
|
pub fn clear_library(weak: Weak<AppWindow>, handle: tokio::runtime::Handle) {
|
||||||
|
let h = handle.clone();
|
||||||
|
handle.spawn(async move {
|
||||||
|
let step1 = rfd::AsyncMessageDialog::new()
|
||||||
|
.set_title("Clear library database?")
|
||||||
|
.set_description(
|
||||||
|
"This removes ALL indexed tracks from the database. Your audio files are NOT deleted. You will need to re-scan your folders afterward.",
|
||||||
|
)
|
||||||
|
.set_buttons(rfd::MessageButtons::YesNo)
|
||||||
|
.show()
|
||||||
|
.await;
|
||||||
|
if step1 != rfd::MessageDialogResult::Yes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let step2 = rfd::AsyncMessageDialog::new()
|
||||||
|
.set_title("Are you absolutely sure?")
|
||||||
|
.set_description("This action cannot be undone.")
|
||||||
|
.set_buttons(rfd::MessageButtons::YesNo)
|
||||||
|
.show()
|
||||||
|
.await;
|
||||||
|
if step2 != rfd::MessageDialogResult::Yes {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibraryFoldersState>().set_clearing_library(true);
|
||||||
|
});
|
||||||
|
let ok = tokio::task::spawn_blocking(|| {
|
||||||
|
crate::library_db::with_db(|db| db.clear_all_tracks()).is_some()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
w.global::<LibraryFoldersState>().set_clearing_library(false);
|
||||||
|
// Reset the browse models so the tabs re-fetch on next visit.
|
||||||
|
let s = w.global::<crate::LocalLibraryState>();
|
||||||
|
let empty_albums = ModelRc::new(VecModel::from(Vec::<crate::AlbumCardItem>::new()));
|
||||||
|
let empty_tracks = ModelRc::new(VecModel::from(Vec::<crate::TrackItem>::new()));
|
||||||
|
s.set_albums(empty_albums.clone());
|
||||||
|
s.set_folders(empty_albums);
|
||||||
|
s.set_tracks(empty_tracks);
|
||||||
|
s.set_artists(ModelRc::new(VecModel::from(Vec::<crate::LocalArtistItem>::new())));
|
||||||
|
});
|
||||||
|
if ok {
|
||||||
|
crate::toast::success_weak(&weak, "Library database cleared");
|
||||||
|
} else {
|
||||||
|
crate::toast::error_weak(&weak, "Couldn't clear the library database");
|
||||||
|
}
|
||||||
|
load_folders(weak, h);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-derive after the filter changed (the text is two-way bound already).
|
||||||
|
pub fn set_filter(weak: Weak<AppWindow>) {
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| derive(&w));
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ mod drag;
|
|||||||
mod folders;
|
mod folders;
|
||||||
mod library_db;
|
mod library_db;
|
||||||
mod local_library;
|
mod local_library;
|
||||||
|
mod local_library_settings;
|
||||||
mod offline;
|
mod offline;
|
||||||
mod offline_cache;
|
mod offline_cache;
|
||||||
mod offline_manager;
|
mod offline_manager;
|
||||||
@@ -4019,18 +4020,112 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
window
|
window
|
||||||
.global::<LocalLibraryActions>()
|
.global::<LocalLibraryActions>()
|
||||||
.on_open_settings(move || {
|
.on_open_settings(move || {
|
||||||
// Management/maintenance/danger actions live under
|
// Management/maintenance/danger live under Settings > Local
|
||||||
// Settings > Local Library. TODO(locallibrary): pre-open the
|
// Library — pre-select that sub-section (index 4). The panel's
|
||||||
// Local Library settings sub-page via target-tab routing once
|
// `init` lazy-loads the folder list.
|
||||||
// that page exists.
|
|
||||||
nav::record(nav::NavEntry::Settings);
|
nav::record(nav::NavEntry::Settings);
|
||||||
if let Some(w) = weak.upgrade() {
|
if let Some(w) = weak.upgrade() {
|
||||||
|
w.global::<SettingsState>().set_section(4);
|
||||||
w.global::<NavState>().set_view(ContentView::Settings);
|
w.global::<NavState>().set_view(ContentView::Settings);
|
||||||
update_nav_flags(&w);
|
update_nav_flags(&w);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings > Local Library — folder management + maintenance + danger.
|
||||||
|
// (Scan callbacks scan-all/scan-folder/stop-scan are wired with Slice B.)
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_load(move || local_library_settings::load_folders(weak.clone(), handle.clone()));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_add_folder(move || local_library_settings::add_folder(weak.clone(), handle.clone()));
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_remove_folders(move || {
|
||||||
|
local_library_settings::remove_folders(weak.clone(), handle.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_toggle_folder_select(move |id| {
|
||||||
|
local_library_settings::toggle_select(weak.clone(), id as i64)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_edit_folder(move |id| {
|
||||||
|
local_library_settings::edit_folder(weak.clone(), handle.clone(), id as i64)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_save_folder_settings(move |id, alias, enabled, is_network, fs_type, user_override| {
|
||||||
|
local_library_settings::save_folder_settings(
|
||||||
|
weak.clone(),
|
||||||
|
handle.clone(),
|
||||||
|
id as i64,
|
||||||
|
alias.to_string(),
|
||||||
|
enabled,
|
||||||
|
is_network,
|
||||||
|
fs_type.to_string(),
|
||||||
|
user_override,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_change_folder_path(move |id| {
|
||||||
|
local_library_settings::change_folder_path(weak.clone(), handle.clone(), id as i64)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_cleanup_missing(move || {
|
||||||
|
local_library_settings::cleanup_missing(weak.clone(), handle.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
let handle = tokio_rt.handle().clone();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_clear_library(move || {
|
||||||
|
local_library_settings::clear_library(weak.clone(), handle.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let weak = window.as_weak();
|
||||||
|
window
|
||||||
|
.global::<LibraryManageActions>()
|
||||||
|
.on_set_filter(move |_q| local_library_settings::set_filter(weak.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
// Local Library — Albums tab controls (search / sort re-query page 1;
|
// Local Library — Albums tab controls (search / sort re-query page 1;
|
||||||
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
// load-more pages on scroll; retry) + the shared AlbumCollectionView's
|
||||||
// open / per-card actions (album-detail + playback land with later slices).
|
// open / per-card actions (album-detail + playback land with later slices).
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint";
|
|||||||
import { Toast } from "primitives/Toast.slint";
|
import { Toast } from "primitives/Toast.slint";
|
||||||
|
|
||||||
// Re-export the state globals so the Rust layer can populate them.
|
// Re-export the state globals so the Rust layer can populate them.
|
||||||
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
|
export { HomeState, HomeActions, DiscoverBrowseState, DiscoverBrowseActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, PlaylistActions, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions, CreateFolderState, CreateFolderActions, DragState, DragActions, PlaylistManagerState, PlaylistManagerActions, OfflineManagerState, OfflineManagerActions, LocalLibraryState, LocalLibraryActions, LibraryFoldersState, LibFolderEditState, LibraryManageActions, FolderEditState, FolderEditActions, ToastState } from "state.slint";
|
||||||
|
|
||||||
// Which top-level screen is shown. The app starts on `splash` while it
|
// Which top-level screen is shown. The app starts on `splash` while it
|
||||||
// restores a saved session, then resolves to `shell` or `login`.
|
// restores a saved session, then resolves to `shell` or `login`.
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
// Folder-settings modal for a registered Local Library folder (Tauri's
|
||||||
|
// FolderSettingsModal). Alias, enabled, network override + fs-type,
|
||||||
|
// accessibility status, change-path, last-scanned, scan-this-folder.
|
||||||
|
//
|
||||||
|
// Driven by LibFolderEditState / LibraryManageActions. Mounted at the
|
||||||
|
// AppShell root (after the other global modals) so it overlays the whole
|
||||||
|
// window and is not clipped by the Settings ScrollView (ADR-009: on top by
|
||||||
|
// mount order, like FolderEditModal/EditPlaylistModal).
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { Radius } from "../foundation/radius.slint";
|
||||||
|
import { LibFolderEditState, LibraryManageActions } from "../state.slint";
|
||||||
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { QbzToggle } from "../primitives/QbzToggle.slint";
|
||||||
|
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
||||||
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
|
import { LineEdit } from "std-widgets.slint";
|
||||||
|
|
||||||
|
// One labeled section block.
|
||||||
|
component Field inherits VerticalLayout {
|
||||||
|
in property <string> label;
|
||||||
|
spacing: 6px;
|
||||||
|
Text {
|
||||||
|
text: root.label;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
}
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
|
||||||
|
export component LibFolderEditModal inherits Rectangle {
|
||||||
|
visible: LibFolderEditState.open;
|
||||||
|
|
||||||
|
// Map the fs-type QbzSelect index to the stored label. Index 0 = "auto"
|
||||||
|
// (let detection decide); the rest map to the modal's option list.
|
||||||
|
function fs-type-string(i: int) -> string {
|
||||||
|
return i == 1 ? "cifs"
|
||||||
|
: i == 2 ? "nfs"
|
||||||
|
: i == 3 ? "sshfs"
|
||||||
|
: i == 4 ? "rclone"
|
||||||
|
: i == 5 ? "webdav"
|
||||||
|
: i == 6 ? "glusterfs"
|
||||||
|
: i == 7 ? "ceph"
|
||||||
|
: i == 8 ? "other"
|
||||||
|
: "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
if LibFolderEditState.open: Rectangle {
|
||||||
|
background: #000000bf;
|
||||||
|
TouchArea {
|
||||||
|
mouse-cursor: default;
|
||||||
|
clicked => { LibFolderEditState.open = false; }
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: Math.min(root.width - 80px, 520px);
|
||||||
|
height: Math.min(root.height - 80px, panel.preferred-height);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
border-radius: Radius.md;
|
||||||
|
background: Theme.surface-card;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
drop-shadow-blur: 32px;
|
||||||
|
drop-shadow-color: #00000080;
|
||||||
|
// Swallow clicks so they don't reach the scrim.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
panel := VerticalLayout {
|
||||||
|
padding: 22px;
|
||||||
|
spacing: 16px;
|
||||||
|
|
||||||
|
// Title + close.
|
||||||
|
HorizontalLayout {
|
||||||
|
Text {
|
||||||
|
text: @tr("Folder settings");
|
||||||
|
color: Theme.text-primary;
|
||||||
|
font-size: Typography.heading;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
close-x := TouchArea {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => { LibFolderEditState.open = false; }
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/x.svg");
|
||||||
|
width: 17px;
|
||||||
|
height: 17px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder location + change-path + accessibility.
|
||||||
|
Field {
|
||||||
|
label: @tr("Folder location");
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: LibFolderEditState.is-network
|
||||||
|
? @image-url("../assets/icons/network.svg")
|
||||||
|
: @image-url("../assets/icons/hard-drive.svg");
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
tint: LibFolderEditState.is-network ? Theme.accent : Theme.text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: LibFolderEditState.path;
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Change folder location.
|
||||||
|
Rectangle {
|
||||||
|
width: change-row.preferred-width + 22px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: change-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
change-row := HorizontalLayout {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
spacing: 6px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/folder-open.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: @tr("Change");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
change-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
LibraryManageActions.change-folder-path(LibFolderEditState.folder-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Accessibility status.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
if LibFolderEditState.checking-accessible: LoadingSpinner { size: 14px; }
|
||||||
|
if !LibFolderEditState.checking-accessible: QbzIcon {
|
||||||
|
source: LibFolderEditState.accessible
|
||||||
|
? @image-url("../assets/icons/check.svg")
|
||||||
|
: @image-url("../assets/icons/x.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: LibFolderEditState.accessible ? #3fae6a : #e0564f;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: LibFolderEditState.checking-accessible
|
||||||
|
? @tr("Checking...")
|
||||||
|
: (LibFolderEditState.accessible
|
||||||
|
? @tr("Folder is accessible")
|
||||||
|
: @tr("Folder is not accessible"));
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias.
|
||||||
|
Field {
|
||||||
|
label: @tr("Display name");
|
||||||
|
LineEdit {
|
||||||
|
placeholder-text: @tr("Optional friendly name");
|
||||||
|
text <=> LibFolderEditState.alias;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Shown instead of the full path in the folder list.");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: @tr("Enabled");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Disabled folders are skipped during scans.");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzToggle {
|
||||||
|
checked: LibFolderEditState.enabled;
|
||||||
|
toggled(v) => { LibFolderEditState.enabled = v; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network override.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: @tr("Network folder");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Mark this folder as a network share (excluded when offline).");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzToggle {
|
||||||
|
checked: LibFolderEditState.is-network;
|
||||||
|
toggled(v) => {
|
||||||
|
LibFolderEditState.is-network = v;
|
||||||
|
LibFolderEditState.user-override-network = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FS type (network only).
|
||||||
|
if LibFolderEditState.is-network: Field {
|
||||||
|
label: @tr("Network filesystem type");
|
||||||
|
QbzSelect {
|
||||||
|
menu-width: 200px;
|
||||||
|
options: [
|
||||||
|
@tr("Auto-detect"),
|
||||||
|
"CIFS / SMB",
|
||||||
|
"NFS",
|
||||||
|
"SSHFS",
|
||||||
|
"rclone",
|
||||||
|
"WebDAV",
|
||||||
|
"GlusterFS",
|
||||||
|
"Ceph",
|
||||||
|
@tr("Other"),
|
||||||
|
];
|
||||||
|
current-index: LibFolderEditState.fs-type-index;
|
||||||
|
selected(i) => { LibFolderEditState.fs-type-index = i; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last scanned.
|
||||||
|
HorizontalLayout {
|
||||||
|
Text {
|
||||||
|
text: @tr("Last scanned");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: LibFolderEditState.last-scan-label;
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 1px; background: Theme.border-subtle; }
|
||||||
|
|
||||||
|
// Footer.
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 10px;
|
||||||
|
// Scan this folder.
|
||||||
|
Rectangle {
|
||||||
|
width: scan-row.preferred-width + 26px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
property <bool> can-scan: LibFolderEditState.enabled && LibFolderEditState.accessible;
|
||||||
|
opacity: self.can-scan ? 1.0 : 0.5;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: scan-ta.has-hover && self.can-scan ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
scan-row := HorizontalLayout {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
spacing: 7px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: @image-url("../assets/icons/refresh-cw.svg");
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Text {
|
||||||
|
text: @tr("Scan this folder");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scan-ta := TouchArea {
|
||||||
|
mouse-cursor: parent.can-scan ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (parent.can-scan) {
|
||||||
|
LibraryManageActions.scan-folder(LibFolderEditState.folder-id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
// Cancel.
|
||||||
|
Rectangle {
|
||||||
|
width: cancel-label.preferred-width + 28px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: cancel-ta.has-hover ? Theme.surface-hover : transparent;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
cancel-label := Text {
|
||||||
|
text: @tr("Cancel");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
cancel-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => { LibFolderEditState.open = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Save.
|
||||||
|
Rectangle {
|
||||||
|
width: save-label.preferred-width + 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: save-ta.has-hover ? Theme.accent-hover : Theme.accent;
|
||||||
|
save-label := Text {
|
||||||
|
text: @tr("Save");
|
||||||
|
color: Theme.accent-text;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
save-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
LibraryManageActions.save-folder-settings(
|
||||||
|
LibFolderEditState.folder-id,
|
||||||
|
LibFolderEditState.alias,
|
||||||
|
LibFolderEditState.enabled,
|
||||||
|
LibFolderEditState.is-network,
|
||||||
|
root.fs-type-string(LibFolderEditState.fs-type-index),
|
||||||
|
LibFolderEditState.user-override-network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
// Settings > Local Library — the folder-management surface relocated here
|
||||||
|
// from the Tauri browse view's inline gear panel (per the existing decision
|
||||||
|
// in local_library.rs: management lives in Settings, the browse-view gear
|
||||||
|
// routes here). Folder list (add/remove/edit/enable) + filter + maintenance
|
||||||
|
// (cleanup missing) + two-step danger-zone clear. The scan engine + progress
|
||||||
|
// bar land with Slice B (LibraryScanState). ADR-008: no pills — plain
|
||||||
|
// bordered icon buttons + square badges.
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
import { Typography } from "../foundation/typography.slint";
|
||||||
|
import { Radius } from "../foundation/radius.slint";
|
||||||
|
import { LibraryFoldersState, LibraryManageActions } from "../state.slint";
|
||||||
|
import { SettingRow } from "SettingRow.slint";
|
||||||
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
|
import { QbzCheckbox } from "../primitives/QbzCheckbox.slint";
|
||||||
|
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
||||||
|
import { ScrollView } from "std-widgets.slint";
|
||||||
|
|
||||||
|
component GroupHeader inherits Text {
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
font-weight: Typography.semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches OfflineSettings/AudioSettings' SecondaryButton.
|
||||||
|
component SecondaryButton inherits Rectangle {
|
||||||
|
in property <string> label;
|
||||||
|
in property <bool> danger: false;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
callback clicked();
|
||||||
|
width: 170px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: root.danger ? #e0564f : Theme.border-subtle;
|
||||||
|
background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
opacity: root.enabled ? 1.0 : 0.5;
|
||||||
|
Text {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
text: root.label;
|
||||||
|
color: root.danger ? #e0564f : Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
vertical-alignment: center;
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: root.enabled ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (root.enabled) { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain bordered icon button for the folder toolbar (ADR-008: NOT a pill).
|
||||||
|
component IconBtn inherits Rectangle {
|
||||||
|
in property <image> icon;
|
||||||
|
in property <bool> enabled: true;
|
||||||
|
callback clicked();
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.border-subtle;
|
||||||
|
background: ta.has-hover && root.enabled ? Theme.surface-hover : Theme.surface-elevated;
|
||||||
|
opacity: root.enabled ? 1.0 : 0.4;
|
||||||
|
QbzIcon {
|
||||||
|
source: root.icon;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
tint: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: root.enabled ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (root.enabled) { root.clicked(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One folder row.
|
||||||
|
component FolderRow inherits Rectangle {
|
||||||
|
in property <string> display-name;
|
||||||
|
in property <bool> enabled-folder;
|
||||||
|
in property <bool> is-network;
|
||||||
|
in property <bool> accessible;
|
||||||
|
in property <bool> selected;
|
||||||
|
callback toggle();
|
||||||
|
height: 48px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: root.selected
|
||||||
|
? Theme.surface-elevated
|
||||||
|
: (ta.has-hover ? Theme.surface-hover : transparent);
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 10px;
|
||||||
|
padding-right: 12px;
|
||||||
|
spacing: 12px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzCheckbox {
|
||||||
|
checked: root.selected;
|
||||||
|
toggled => { root.toggle(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
QbzIcon {
|
||||||
|
source: root.is-network
|
||||||
|
? @image-url("../assets/icons/network.svg")
|
||||||
|
: @image-url("../assets/icons/hard-drive.svg");
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
tint: root.is-network
|
||||||
|
? (root.accessible ? #3fae6a : #e0564f)
|
||||||
|
: Theme.text-secondary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
Text {
|
||||||
|
text: root.display-name;
|
||||||
|
color: root.enabled-folder ? Theme.text-primary : Theme.text-muted;
|
||||||
|
font-size: Typography.body;
|
||||||
|
overflow: elide;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Square status badge (ADR-008: plain text, not a pill).
|
||||||
|
if !root.enabled-folder || (root.is-network && !root.accessible): VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
Rectangle {
|
||||||
|
width: badge-text.preferred-width + 16px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: !root.enabled-folder ? Theme.border-subtle : #e0564f;
|
||||||
|
badge-text := Text {
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
text: !root.enabled-folder ? @tr("Disabled") : @tr("Unavailable");
|
||||||
|
color: !root.enabled-folder ? Theme.text-muted : #e0564f;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => { root.toggle(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component LocalLibrarySettings inherits VerticalLayout {
|
||||||
|
spacing: 4px;
|
||||||
|
|
||||||
|
// Lazy-load the folder list each time the panel is shown (the `if
|
||||||
|
// section==4` mount in SettingsView creates this fresh on entry).
|
||||||
|
init => {
|
||||||
|
LibraryManageActions.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Library folders -------------------------------------------------
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 8px;
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
GroupHeader { text: @tr("LIBRARY FOLDERS"); }
|
||||||
|
}
|
||||||
|
// Toolbar: scan-all / add / edit (one selected) / remove (any selected).
|
||||||
|
IconBtn {
|
||||||
|
icon: @image-url("../assets/icons/refresh-cw.svg");
|
||||||
|
clicked => { LibraryManageActions.scan-all(); }
|
||||||
|
}
|
||||||
|
IconBtn {
|
||||||
|
icon: @image-url("../assets/icons/folder-plus.svg");
|
||||||
|
clicked => { LibraryManageActions.add-folder(); }
|
||||||
|
}
|
||||||
|
IconBtn {
|
||||||
|
icon: @image-url("../assets/icons/pencil.svg");
|
||||||
|
enabled: LibraryFoldersState.selected-count == 1;
|
||||||
|
clicked => { LibraryManageActions.edit-folder(0); }
|
||||||
|
}
|
||||||
|
IconBtn {
|
||||||
|
icon: @image-url("../assets/icons/trash-2.svg");
|
||||||
|
enabled: LibraryFoldersState.selected-count > 0;
|
||||||
|
clicked => { LibraryManageActions.remove-folders(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 6px; }
|
||||||
|
|
||||||
|
// Filter.
|
||||||
|
HorizontalLayout {
|
||||||
|
ExpandableSearch {
|
||||||
|
placeholder: @tr("Filter folders...");
|
||||||
|
sm: true;
|
||||||
|
query <=> LibraryFoldersState.filter;
|
||||||
|
edited(q) => { LibraryManageActions.set-filter(q); }
|
||||||
|
}
|
||||||
|
Rectangle { horizontal-stretch: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 8px; }
|
||||||
|
|
||||||
|
// Folder list.
|
||||||
|
if LibraryFoldersState.folders.length == 0 && !LibraryFoldersState.loading: Text {
|
||||||
|
text: LibraryFoldersState.filter == ""
|
||||||
|
? @tr("No folders yet. Add a folder to build your local library.")
|
||||||
|
: @tr("No folders match your filter.");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.body;
|
||||||
|
}
|
||||||
|
if LibraryFoldersState.folders.length > 0: Rectangle {
|
||||||
|
height: Math.min(LibraryFoldersState.folders.length * 50px, 380px);
|
||||||
|
ScrollView {
|
||||||
|
horizontal-scrollbar-policy: ScrollBarPolicy.always-off;
|
||||||
|
VerticalLayout {
|
||||||
|
spacing: 2px;
|
||||||
|
for f in LibraryFoldersState.folders: FolderRow {
|
||||||
|
display-name: f.display-name;
|
||||||
|
enabled-folder: f.enabled;
|
||||||
|
is-network: f.is-network;
|
||||||
|
accessible: f.accessible;
|
||||||
|
selected: f.selected;
|
||||||
|
toggle => { LibraryManageActions.toggle-folder-select(f.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 22px; }
|
||||||
|
|
||||||
|
// --- Maintenance -----------------------------------------------------
|
||||||
|
GroupHeader { text: @tr("MAINTENANCE"); }
|
||||||
|
SettingRow {
|
||||||
|
label: @tr("Cleanup missing files");
|
||||||
|
description: @tr("Remove tracks whose files no longer exist on disk.");
|
||||||
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 4px;
|
||||||
|
SecondaryButton {
|
||||||
|
label: LibraryFoldersState.cleaning-missing ? @tr("Cleaning up...") : @tr("Cleanup");
|
||||||
|
enabled: !LibraryFoldersState.cleaning-missing;
|
||||||
|
clicked => { LibraryManageActions.cleanup-missing(); }
|
||||||
|
}
|
||||||
|
if LibraryFoldersState.cleanup-status != "": Text {
|
||||||
|
text: LibraryFoldersState.cleanup-status;
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
horizontal-alignment: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { height: 22px; }
|
||||||
|
|
||||||
|
// --- Danger zone -----------------------------------------------------
|
||||||
|
GroupHeader { text: @tr("DANGER ZONE"); }
|
||||||
|
SettingRow {
|
||||||
|
label: @tr("Clear library database");
|
||||||
|
description: @tr("Remove all indexed tracks. Your audio files are not deleted.");
|
||||||
|
SecondaryButton {
|
||||||
|
label: LibraryFoldersState.clearing-library ? @tr("Clearing...") : @tr("Clear all");
|
||||||
|
danger: true;
|
||||||
|
enabled: !LibraryFoldersState.clearing-library;
|
||||||
|
clicked => { LibraryManageActions.clear-library(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import { AudioSettings } from "AudioSettings.slint";
|
|||||||
import { PlaybackSettings } from "PlaybackSettings.slint";
|
import { PlaybackSettings } from "PlaybackSettings.slint";
|
||||||
import { AppearanceSettings } from "AppearanceSettings.slint";
|
import { AppearanceSettings } from "AppearanceSettings.slint";
|
||||||
import { OfflineSettings } from "OfflineSettings.slint";
|
import { OfflineSettings } from "OfflineSettings.slint";
|
||||||
|
import { LocalLibrarySettings } from "LocalLibrarySettings.slint";
|
||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView } from "std-widgets.slint";
|
||||||
|
|
||||||
component SubNavItem inherits Rectangle {
|
component SubNavItem inherits Rectangle {
|
||||||
@@ -139,6 +140,14 @@ export component SettingsView inherits Rectangle {
|
|||||||
SettingsState.section = 3;
|
SettingsState.section = 3;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SubNavItem {
|
||||||
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
||||||
|
label: @tr("Local Library");
|
||||||
|
active: SettingsState.section == 4;
|
||||||
|
clicked => {
|
||||||
|
SettingsState.section = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +195,7 @@ export component SettingsView inherits Rectangle {
|
|||||||
// SettingsView-level callback forwarding is needed here.
|
// SettingsView-level callback forwarding is needed here.
|
||||||
if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { }
|
if !SettingsState.loading && SettingsState.section == 2: AppearanceSettings { }
|
||||||
if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { }
|
if !SettingsState.loading && SettingsState.section == 3: OfflineSettings { }
|
||||||
|
if !SettingsState.loading && SettingsState.section == 4: LocalLibrarySettings { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
|
|||||||
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
|
||||||
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
import { CreateFolderModal } from "../primitives/CreateFolderModal.slint";
|
||||||
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
import { FolderEditModal } from "../primitives/FolderEditModal.slint";
|
||||||
|
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
|
||||||
import { DragGhost } from "../primitives/DragGhost.slint";
|
import { DragGhost } from "../primitives/DragGhost.slint";
|
||||||
import { PlayerBar } from "PlayerBar.slint";
|
import { PlayerBar } from "PlayerBar.slint";
|
||||||
import { QueueSidebar } from "QueueSidebar.slint";
|
import { QueueSidebar } from "QueueSidebar.slint";
|
||||||
@@ -280,6 +281,9 @@ export component AppShell inherits Rectangle {
|
|||||||
// Global "Folder editor" modal (Playlist Manager create/edit folder).
|
// Global "Folder editor" modal (Playlist Manager create/edit folder).
|
||||||
FolderEditModal { }
|
FolderEditModal { }
|
||||||
|
|
||||||
|
// Global "Local Library folder settings" modal (Settings > Local Library).
|
||||||
|
LibFolderEditModal { }
|
||||||
|
|
||||||
// Floating track-drag ghost (follows the cursor, on top of all).
|
// Floating track-drag ghost (follows the cursor, on top of all).
|
||||||
DragGhost { }
|
DragGhost { }
|
||||||
}
|
}
|
||||||
@@ -508,6 +508,73 @@ export global LocalLibraryActions {
|
|||||||
callback artists-select(string /* artist name */);
|
callback artists-select(string /* artist name */);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================ Local Library management ====================
|
||||||
|
// The folder list + scan + maintenance + danger zone live under
|
||||||
|
// Settings > Local Library (NOT the browse view). One row per registered
|
||||||
|
// library folder; `Option<T>` maps to a "" / 0 sentinel + a Rust-computed
|
||||||
|
// label where the raw value is ambiguous.
|
||||||
|
|
||||||
|
// One registered library folder. Mirrors `qbz_library::LibraryFolder`
|
||||||
|
// (8 DB columns) + a UI-owned `selected` checkbox + Rust-computed labels.
|
||||||
|
export struct LibraryFolderItem {
|
||||||
|
id: int, // i64 folder id (small)
|
||||||
|
path: string, // absolute path
|
||||||
|
alias: string, // "" when none
|
||||||
|
display-name: string, // alias if non-empty else path (Rust-computed)
|
||||||
|
enabled: bool,
|
||||||
|
is-network: bool,
|
||||||
|
network-fs-type: string, // "" when none (e.g. "nfs","cifs")
|
||||||
|
user-override-network: bool,
|
||||||
|
last-scan: int, // unix seconds; 0 = never
|
||||||
|
last-scan-label: string, // "Never" / localized date (Rust-computed)
|
||||||
|
accessible: bool, // from the accessibility check; true until resolved
|
||||||
|
selected: bool, // UI multi-select state
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folder list + maintenance + danger-zone state (Settings > Local Library).
|
||||||
|
export global LibraryFoldersState {
|
||||||
|
in property <[LibraryFolderItem]> folders: [];
|
||||||
|
in property <bool> loading: false;
|
||||||
|
in-out property <string> filter: ""; // folder search (alias??path substring)
|
||||||
|
in property <int> selected-count: 0; // drives Edit(==1) / Remove(>0) enablement
|
||||||
|
in property <bool> cleaning-missing: false;
|
||||||
|
in property <string> cleanup-status: ""; // "Removed N of M" / "" (auto-clears, Rust-side)
|
||||||
|
in property <bool> clearing-library: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder-settings modal state (separate from the playlist FolderEditState).
|
||||||
|
export global LibFolderEditState {
|
||||||
|
in-out property <bool> open: false;
|
||||||
|
in property <int> folder-id: 0;
|
||||||
|
in-out property <string> alias: "";
|
||||||
|
in property <string> path: ""; // current path (updated after change-path)
|
||||||
|
in-out property <bool> enabled: true;
|
||||||
|
in-out property <bool> is-network: false;
|
||||||
|
in-out property <bool> user-override-network: false;
|
||||||
|
// QbzSelect index: 0 auto,1 cifs,2 nfs,3 sshfs,4 rclone,5 webdav,6 glusterfs,7 ceph,8 other.
|
||||||
|
in-out property <int> fs-type-index: 0;
|
||||||
|
in property <bool> accessible: true;
|
||||||
|
in property <bool> checking-accessible: false;
|
||||||
|
in property <string> last-scan-label: ""; // "Never" / date
|
||||||
|
}
|
||||||
|
|
||||||
|
export global LibraryManageActions {
|
||||||
|
callback load(); // (re)load the folder list (panel init)
|
||||||
|
callback add-folder(); // native dir picker (Rust)
|
||||||
|
callback remove-folders(); // bulk-remove selected (confirm)
|
||||||
|
callback toggle-folder-select(int /* id */);
|
||||||
|
callback edit-folder(int /* id */); // open the settings modal
|
||||||
|
callback save-folder-settings(int /* id */, string /* alias */, bool /* enabled */,
|
||||||
|
bool /* is-network */, string /* fs-type */, bool /* user-override */);
|
||||||
|
callback change-folder-path(int /* id */); // picker -> update path
|
||||||
|
callback scan-all();
|
||||||
|
callback scan-folder(int /* id */);
|
||||||
|
callback stop-scan();
|
||||||
|
callback cleanup-missing();
|
||||||
|
callback clear-library(); // two-step confirm
|
||||||
|
callback set-filter(string /* query */);
|
||||||
|
}
|
||||||
|
|
||||||
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
// Global toast state, driven by crate::toast. The Toast overlay (mounted
|
||||||
// at the app root) renders whatever is here. The Rust layer owns timing
|
// at the app root) renders whatever is here. The Rust layer owns timing
|
||||||
// (show + auto-hide); the UI only sets `visible = false` on the close
|
// (show + auto-hide); the UI only sets `visible = false` on the close
|
||||||
|
|||||||
Reference in new issue
Block a user