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:
vicrodh committed 2026-05-29 13:07:19 -06:00
1 parent 5986779d85
commit 3826298f77
11 files changed
+1527 -6

No files matched your search

+25
View File
@@ -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
+1 -1
View File
@@ -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,
+57
View File
@@ -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));
}
+99 -4
View File
@@ -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).
+1 -1
View File
@@ -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 { }
} }
} }
} }
+4
View File
@@ -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 { }
} }
+67
View File
@@ -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