diff --git a/crates/qbz-library/src/lib.rs b/crates/qbz-library/src/lib.rs index ed133bc20..821550a4a 100644 --- a/crates/qbz-library/src/lib.rs +++ b/crates/qbz-library/src/lib.rs @@ -35,6 +35,7 @@ mod errors; mod metadata; mod models; mod mount_info; +mod scan; mod scanner; mod tag_sidecar; mod thumbnails; @@ -49,6 +50,7 @@ pub use errors::LibraryError; pub use metadata::MetadataExtractor; pub use models::*; pub use mount_info::{is_network_path, network_fs_label}; +pub use scan::{scan_with_progress, ScanEvent}; pub use scanner::{LibraryScanner, ScanResult}; pub use thumbnails::{ clear_thumbnails, generate_thumbnail, generate_thumbnail_from_bytes, get_cache_size, diff --git a/crates/qbz-library/src/scan.rs b/crates/qbz-library/src/scan.rs new file mode 100644 index 000000000..811dcd033 --- /dev/null +++ b/crates/qbz-library/src/scan.rs @@ -0,0 +1,362 @@ +//! Progress-emitting library scan (frontend-agnostic). +//! +//! Lifts the scan-orchestration loop that lived in the Tauri command +//! `v2_library_scan` / `v2_library_scan_folder` into the core crate so any +//! frontend (Slint, TUI) can drive it. The Tauri side polled an +//! `Arc>` over the IPC boundary; in-process callers get +//! the same information pushed through `on_event` and check `cancel` at every +//! file boundary. The per-file logic (CUE-first, sidecar override, embedded → +//! folder artwork, insert, missing-file cleanup) is replicated exactly. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::{ + cue_to_tracks, AlbumTagSidecar, CueParser, LibraryDatabase, LibraryError, LibraryScanner, + LocalTrack, MetadataExtractor, ScanError, ScanStatus, +}; + +/// One step of a scan, pushed to the caller. The caller maps these onto its +/// own progress surface (and may coalesce the per-file stream). +pub enum ScanEvent { + /// Scan started (status = Scanning, counters reset). + Started, + /// A folder's file count was folded into the running total. + TotalsAdded { total: u32 }, + /// A file is about to be processed (caller trims to a basename). + FileStarted { path: String }, + /// A file finished (processed/total advanced). + FileDone { processed: u32, total: u32 }, + /// Entering the missing-file cleanup phase. + Cleanup, + /// Terminal: Complete / Cancelled / Error, with any per-file errors. + Finished { + status: ScanStatus, + errors: Vec, + }, +} + +fn normalize_path(path: &Path) -> PathBuf { + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Apply a per-album sidecar override to a scanned track, caching the sidecar +/// read per album group key. Mirrors Tauri's +/// `library_apply_sidecar_override_if_present`. +fn apply_sidecar_override( + track: &mut LocalTrack, + cache: &mut HashMap>, +) { + let group_key = track.album_group_key.trim(); + if group_key.is_empty() { + return; + } + let cached = cache.entry(group_key.to_string()).or_insert_with(|| { + let album_dir = Path::new(group_key); + if !album_dir.is_dir() { + return None; + } + crate::tag_sidecar::read_album_sidecar(album_dir).unwrap_or(None) + }); + if let Some(sidecar) = cached.as_ref() { + crate::tag_sidecar::apply_sidecar_to_track(track, sidecar); + } +} + +/// Parse a CUE sheet into its virtual tracks and insert them. Mirrors Tauri's +/// `library_process_cue_file` (the per-track artwork loop already covers what +/// the deprecated `update_album_group_artwork` shortcut did, so it is omitted). +fn process_cue_file( + db: &LibraryDatabase, + cue_path: &Path, + artwork_cache: &Path, +) -> Result<(), String> { + let mut cue = CueParser::parse(cue_path).map_err(|e| e.to_string())?; + let audio_path = normalize_path(Path::new(&cue.audio_file)); + if !audio_path.exists() { + return Err(format!("Audio file not found: {}", cue.audio_file)); + } + cue.audio_file = audio_path.to_string_lossy().to_string(); + + let properties = MetadataExtractor::extract_properties(&audio_path).map_err(|e| e.to_string())?; + let format = MetadataExtractor::detect_format(&audio_path); + let mut tracks = cue_to_tracks(&cue, properties.duration_secs, format, &properties); + + if let Some(group_key) = tracks + .first() + .map(|t| t.album_group_key.trim().to_string()) + .filter(|k| !k.is_empty()) + { + let album_dir = Path::new(&group_key); + if album_dir.is_dir() { + if let Ok(Some(sidecar)) = crate::tag_sidecar::read_album_sidecar(album_dir) { + for t in tracks.iter_mut() { + crate::tag_sidecar::apply_sidecar_to_track(t, &sidecar); + } + } + } + } + + let mut artwork = MetadataExtractor::extract_artwork(&audio_path, artwork_cache); + if artwork.is_none() { + if let Some(folder_art) = + MetadataExtractor::find_folder_artwork(&audio_path, cue.title.as_deref()) + { + artwork = + MetadataExtractor::cache_artwork_file(Path::new(&folder_art), artwork_cache); + } + } + if let Some(p) = artwork.as_ref() { + for t in tracks.iter_mut() { + t.artwork_path = Some(p.clone()); + } + } + + for track in &tracks { + db.insert_track(track).map_err(|e| e.to_string())?; + } + Ok(()) +} + +/// Scan the library (or a single folder set) with progress + cancellation. +/// +/// `folder_ids = None` scans every ENABLED folder (full scan); `Some(&[id])` +/// scans only those enabled folders (single/per-folder parity). `artwork_cache` +/// is the cache dir for extracted/copied covers. `cancel` is checked at every +/// file boundary; on cancel the scan returns early with `Finished{Cancelled}` +/// and does NOT run cleanup (Tauri parity). `on_event` receives each step. +/// +/// Improvements over the Tauri loop (both verified against source): +/// - the full scan updates each folder's `last_scan` on success (Tauri only +/// did this for single-folder scans); +/// - network status is re-detected for every scanned folder that is not +/// user-overridden (Tauri only did this for single-folder scans). +pub fn scan_with_progress( + db: &LibraryDatabase, + folder_ids: Option<&[i64]>, + artwork_cache: &Path, + cancel: &AtomicBool, + on_event: &(dyn Fn(ScanEvent) + Send + Sync), +) -> Result<(), LibraryError> { + let all = db.get_folders_with_metadata()?; + let targets: Vec = match folder_ids { + None => all.into_iter().filter(|f| f.enabled).collect(), + Some(ids) => all + .into_iter() + .filter(|f| f.enabled && ids.contains(&f.id)) + .collect(), + }; + if targets.is_empty() { + return Err(LibraryError::Other("No library folders to scan".to_string())); + } + let single = folder_ids.is_some(); + + // Refresh network detection for non-overridden folders being scanned. + for f in &targets { + if f.user_override_network { + continue; + } + let p = Path::new(&f.path); + let is_net = crate::mount_info::is_network_path(p); + if is_net != f.is_network { + let fs = if is_net { + crate::mount_info::network_fs_label(p) + } else { + None + }; + let _ = db.update_folder_settings( + f.id, + f.alias.as_deref(), + f.enabled, + is_net, + fs.as_deref(), + false, + ); + } + } + + on_event(ScanEvent::Started); + + let scanner = LibraryScanner::new(); + let mut all_errors: Vec = Vec::new(); + let mut sidecar_cache: HashMap> = HashMap::new(); + let mut total: u32 = 0; + let mut processed: u32 = 0; + + for folder in &targets { + let scan_result = match scanner.scan_directory(Path::new(&folder.path)) { + Ok(r) => r, + Err(e) => { + all_errors.push(ScanError { + file_path: folder.path.clone(), + error: e.to_string(), + }); + continue; + } + }; + + total += (scan_result.audio_files.len() + scan_result.cue_files.len()) as u32; + on_event(ScanEvent::TotalsAdded { total }); + + // CUE files first (one file -> several virtual tracks). + for cue_path in &scan_result.cue_files { + if cancel.load(Ordering::Relaxed) { + on_event(ScanEvent::Finished { + status: ScanStatus::Cancelled, + errors: std::mem::take(&mut all_errors), + }); + return Ok(()); + } + on_event(ScanEvent::FileStarted { + path: cue_path.to_string_lossy().to_string(), + }); + if let Err(e) = process_cue_file(db, cue_path, artwork_cache) { + all_errors.push(ScanError { + file_path: cue_path.to_string_lossy().to_string(), + error: e, + }); + } + processed += 1; + on_event(ScanEvent::FileDone { processed, total }); + } + + // Audio files (skipping any referenced by a CUE sheet). + let cue_audio_files: HashSet = scan_result + .cue_files + .iter() + .filter_map(|p| { + CueParser::parse(p).ok().map(|cue| { + normalize_path(Path::new(&cue.audio_file)) + .to_string_lossy() + .to_string() + }) + }) + .collect(); + + let mut folder_artwork_cache: HashMap> = HashMap::new(); + + for audio_path in &scan_result.audio_files { + if cancel.load(Ordering::Relaxed) { + on_event(ScanEvent::Finished { + status: ScanStatus::Cancelled, + errors: std::mem::take(&mut all_errors), + }); + return Ok(()); + } + + let canonical = normalize_path(audio_path); + let path_str = canonical.to_string_lossy().to_string(); + if cue_audio_files.contains(&path_str) { + processed += 1; + on_event(ScanEvent::FileDone { processed, total }); + continue; + } + + on_event(ScanEvent::FileStarted { + path: path_str.clone(), + }); + + match MetadataExtractor::extract(&canonical) { + Ok(mut track) => { + apply_sidecar_override(&mut track, &mut sidecar_cache); + let mut artwork = MetadataExtractor::extract_artwork(&canonical, artwork_cache); + if artwork.is_none() { + let album_hint: Option = if !track.album_group_title.is_empty() { + Some(track.album_group_title.clone()) + } else { + Some(track.album.clone()) + }; + let folder_dir = canonical + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| canonical.clone()); + let cached = folder_artwork_cache + .entry(folder_dir) + .or_insert_with(|| { + MetadataExtractor::find_folder_artwork( + &canonical, + album_hint.as_deref(), + ) + }) + .clone(); + if let Some(folder_art) = cached { + artwork = MetadataExtractor::cache_artwork_file( + Path::new(&folder_art), + artwork_cache, + ); + } + } + track.artwork_path = artwork; + + if let Err(e) = db.insert_track(&track) { + all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }); + } + } + Err(e) => all_errors.push(ScanError { + file_path: path_str, + error: e.to_string(), + }), + } + + processed += 1; + on_event(ScanEvent::FileDone { processed, total }); + } + } + + // Cleanup: remove tracks whose files no longer exist. Full scan checks the + // whole DB; single-folder scan only the scanned folders' subtrees. + on_event(ScanEvent::Cleanup); + if let Ok(tracks) = db.get_all_track_paths() { + let missing: Vec = if single { + let prefixes: Vec = targets + .iter() + .map(|f| { + if f.path.ends_with('/') { + f.path.clone() + } else { + format!("{}/", f.path) + } + }) + .collect(); + tracks + .iter() + .filter(|(_, p)| { + prefixes.iter().any(|pre| p.starts_with(pre)) && !Path::new(p).exists() + }) + .map(|(id, _)| *id) + .collect() + } else { + tracks + .iter() + .filter(|(_, p)| !Path::new(p).exists()) + .map(|(id, _)| *id) + .collect() + }; + for chunk in missing.chunks(500) { + let _ = db.delete_tracks_by_ids(chunk); + } + } + + // Stamp each scanned folder's last_scan (improvement: full scan too). + let now = now_secs(); + for f in &targets { + let _ = db.update_folder_scan_time(&f.path, now); + } + + on_event(ScanEvent::Finished { + status: ScanStatus::Complete, + errors: all_errors, + }); + Ok(()) +} diff --git a/crates/qbz-slint/src/local_library_settings.rs b/crates/qbz-slint/src/local_library_settings.rs index 8a336ad98..6774c8bed 100644 --- a/crates/qbz-slint/src/local_library_settings.rs +++ b/crates/qbz-slint/src/local_library_settings.rs @@ -11,12 +11,14 @@ //! 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 std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; use slint::{ComponentHandle, ModelRc, VecModel, Weak}; -use crate::{AppWindow, LibFolderEditState, LibraryFolderItem, LibraryFoldersState}; +use crate::{ + AppWindow, LibFolderEditState, LibraryFolderItem, LibraryFoldersState, LibraryScanState, +}; /// One registered folder + its UI selection state. The authoritative copy; /// the Slint model is derived (filtered) from this. @@ -588,3 +590,166 @@ pub fn clear_library(weak: Weak, handle: tokio::runtime::Handle) { pub fn set_filter(weak: Weak) { let _ = weak.upgrade_in_event_loop(|w| derive(&w)); } + +// ================================ Scan ==================================== + +/// Cancel token for the running scan (the port's equivalent of Tauri's +/// `LibraryState.scan_cancel`). `stop_scan` sets it; the core loop checks it +/// at every file boundary. +static SCAN_CANCEL: LazyLock> = LazyLock::new(|| Arc::new(AtomicBool::new(false))); + +fn basename(path: &str) -> String { + path.trim_end_matches('/') + .rsplit('/') + .next() + .unwrap_or(path) + .to_string() +} + +/// ~100ms coalescing for the per-file event stream (a 16K-track scan would +/// otherwise flood the event loop). Terminal/phase events bypass this. +fn throttle_ok(last: &Mutex) -> bool { + let mut g = last.lock().unwrap_or_else(|e| e.into_inner()); + if g.elapsed() >= std::time::Duration::from_millis(100) { + *g = std::time::Instant::now(); + true + } else { + false + } +} + +/// Run a scan (full when `ids` is None, else the given enabled folders) on a +/// blocking thread, pushing throttled progress to `LibraryScanState`. On +/// finish: reload the folder list, reset the browse models so the tabs +/// re-fetch, and toast the outcome. +fn run_scan(weak: Weak, handle: tokio::runtime::Handle, ids: Option>) { + SCAN_CANCEL.store(false, Ordering::SeqCst); + let _ = weak.upgrade_in_event_loop(|w| { + let s = w.global::(); + s.set_scanning(true); + s.set_scan_status(1); + s.set_total_files(0); + s.set_processed_files(0); + s.set_progress(0.0); + s.set_current_file("".into()); + s.set_error_count(0); + }); + + let h = handle.clone(); + handle.spawn_blocking(move || { + let artwork_cache = crate::library_db::artwork_cache_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")); + let cancel = SCAN_CANCEL.clone(); + let weak_sink = weak.clone(); + let last = Mutex::new(std::time::Instant::now()); + + let sink = move |ev: qbz_library::ScanEvent| { + use qbz_library::ScanEvent::*; + match ev { + Started => {} + TotalsAdded { total } => { + let _ = weak_sink.upgrade_in_event_loop(move |w| { + w.global::().set_total_files(total as i32); + }); + } + FileStarted { path } => { + if throttle_ok(&last) { + let base = basename(&path); + let _ = weak_sink.upgrade_in_event_loop(move |w| { + w.global::().set_current_file(base.into()); + }); + } + } + FileDone { processed, total } => { + if throttle_ok(&last) { + let _ = weak_sink.upgrade_in_event_loop(move |w| { + let s = w.global::(); + s.set_processed_files(processed as i32); + s.set_total_files(total as i32); + s.set_progress(if total > 0 { + (processed as f32 / total as f32).min(1.0) + } else { + 0.0 + }); + }); + } + } + Cleanup => { + let _ = weak_sink.upgrade_in_event_loop(|w| { + w.global::() + .set_current_file("Cleaning up missing files...".into()); + }); + } + Finished { status, errors } => { + let st = match status { + qbz_library::ScanStatus::Complete => 2, + qbz_library::ScanStatus::Cancelled => 3, + qbz_library::ScanStatus::Error => 4, + _ => 0, + }; + let ec = errors.len() as i32; + let _ = weak_sink.upgrade_in_event_loop(move |w| { + let s = w.global::(); + s.set_scanning(false); + s.set_scan_status(st); + s.set_error_count(ec); + s.set_current_file("".into()); + if st == 2 { + s.set_progress(1.0); + } + }); + match st { + 2 if ec > 0 => crate::toast::success_weak( + &weak_sink, + format!("Scan complete ({ec} file(s) skipped)"), + ), + 2 => crate::toast::success_weak(&weak_sink, "Scan complete"), + 3 => crate::toast::success_weak(&weak_sink, "Scan cancelled"), + _ => crate::toast::error_weak(&weak_sink, "Scan failed"), + } + } + } + }; + + let ids_ref = ids.as_deref(); + let _ = crate::library_db::with_db(|db| { + qbz_library::scan_with_progress(db, ids_ref, &artwork_cache, &cancel, &sink) + }); + + // Post-scan: refresh the folder list (last_scan labels) + reset the + // browse models so the tabs re-fetch the new index on next visit. + let _ = weak.upgrade_in_event_loop(|w| { + let s = w.global::(); + let empty_albums = ModelRc::new(VecModel::from(Vec::::new())); + let empty_tracks = ModelRc::new(VecModel::from(Vec::::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::::new()))); + }); + load_folders(weak, h); + }); +} + +/// Scan every enabled folder. Guards on an empty list. +pub fn scan_all(weak: Weak, handle: tokio::runtime::Handle) { + let empty = folders_lock().is_empty(); + if empty { + crate::toast::error_weak(&weak, "Add a folder before scanning"); + return; + } + run_scan(weak, handle, None); +} + +/// Scan a single folder (from the settings modal). Closes the modal first. +pub fn scan_folder(weak: Weak, handle: tokio::runtime::Handle, id: i64) { + let _ = weak.upgrade_in_event_loop(|w| { + w.global::().set_open(false); + }); + run_scan(weak, handle, Some(vec![id])); +} + +/// Request cancellation of the running scan. +pub fn stop_scan() { + SCAN_CANCEL.store(true, Ordering::SeqCst); +} diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 4bd3f99f4..3fce2d032 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -4125,6 +4125,27 @@ fn main() -> Result<(), Box> { .global::() .on_set_filter(move |_q| local_library_settings::set_filter(weak.clone())); } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_scan_all(move || local_library_settings::scan_all(weak.clone(), handle.clone())); + } + { + let weak = window.as_weak(); + let handle = tokio_rt.handle().clone(); + window + .global::() + .on_scan_folder(move |id| { + local_library_settings::scan_folder(weak.clone(), handle.clone(), id as i64) + }); + } + { + window + .global::() + .on_stop_scan(move || local_library_settings::stop_scan()); + } // Local Library — Albums tab controls (search / sort re-query page 1; // load-more pages on scroll; retry) + the shared AlbumCollectionView's diff --git a/crates/qbz-slint/ui/app.slint b/crates/qbz-slint/ui/app.slint index 3e83946fe..2a37420ca 100644 --- a/crates/qbz-slint/ui/app.slint +++ b/crates/qbz-slint/ui/app.slint @@ -18,7 +18,7 @@ import { AppShell } from "shell/AppShell.slint"; import { Toast } from "primitives/Toast.slint"; // 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, LibraryFoldersState, LibFolderEditState, LibraryManageActions, 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, LibraryScanState, FolderEditState, FolderEditActions, ToastState } from "state.slint"; // Which top-level screen is shown. The app starts on `splash` while it // restores a saved session, then resolves to `shell` or `login`. diff --git a/crates/qbz-slint/ui/settings/LocalLibrarySettings.slint b/crates/qbz-slint/ui/settings/LocalLibrarySettings.slint index 2325ebebe..ddf014457 100644 --- a/crates/qbz-slint/ui/settings/LocalLibrarySettings.slint +++ b/crates/qbz-slint/ui/settings/LocalLibrarySettings.slint @@ -9,7 +9,7 @@ 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 { LibraryFoldersState, LibraryManageActions, LibraryScanState } from "../state.slint"; import { SettingRow } from "SettingRow.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzCheckbox } from "../primitives/QbzCheckbox.slint"; @@ -178,6 +178,7 @@ export component LocalLibrarySettings inherits VerticalLayout { // Toolbar: scan-all / add / edit (one selected) / remove (any selected). IconBtn { icon: @image-url("../assets/icons/refresh-cw.svg"); + enabled: !LibraryScanState.scanning; clicked => { LibraryManageActions.scan-all(); } } IconBtn { @@ -237,6 +238,82 @@ export component LocalLibrarySettings inherits VerticalLayout { } } + // --- Scan progress (determinate; only while scanning) ---------------- + if LibraryScanState.scanning: Rectangle { + height: scan-box.preferred-height + 24px; + VerticalLayout { + scan-box := VerticalLayout { + padding-top: 14px; + spacing: 8px; + HorizontalLayout { + Text { + text: @tr("Scanning") + ": \{LibraryScanState.processed-files} / \{LibraryScanState.total-files}"; + color: Theme.text-secondary; + font-size: Typography.body; + font-weight: Typography.medium; + horizontal-stretch: 1; + vertical-alignment: center; + } + // Stop scan. + Rectangle { + width: stop-row.preferred-width + 22px; + height: 30px; + border-radius: Radius.sm; + border-width: 1px; + border-color: #e0564f; + background: stop-ta.has-hover ? #e0564f22 : transparent; + stop-row := HorizontalLayout { + x: (parent.width - self.width) / 2; + spacing: 6px; + VerticalLayout { + alignment: center; + QbzIcon { + source: @image-url("../assets/icons/x.svg"); + width: 13px; + height: 13px; + tint: #e0564f; + } + } + VerticalLayout { + alignment: center; + Text { + text: @tr("Stop"); + color: #e0564f; + font-size: Typography.legal; + font-weight: Typography.semibold; + } + } + } + stop-ta := TouchArea { + mouse-cursor: pointer; + clicked => { LibraryManageActions.stop-scan(); } + } + } + } + // Determinate bar (2 rectangles; ADR-008-clean). + Rectangle { + height: 6px; + border-radius: 3px; + background: Theme.surface-elevated; + clip: true; + Rectangle { + x: 0; + width: parent.width * LibraryScanState.progress; + height: parent.height; + border-radius: 3px; + background: Theme.accent; + } + } + if LibraryScanState.current-file != "": Text { + text: LibraryScanState.current-file; + color: Theme.text-muted; + font-size: Typography.legal; + overflow: elide; + } + } + } + } + Rectangle { height: 22px; } // --- Maintenance ----------------------------------------------------- diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index 7f006f40b..4edd69988 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -558,6 +558,18 @@ export global LibFolderEditState { in property last-scan-label: ""; // "Never" / date } +// Library scan progress — a single global poll target (scan is not per-row, +// unlike the offline cache). `progress` is computed Rust-side (total==0 guard). +export global LibraryScanState { + in property scanning: false; // status == Scanning + in property scan-status: 0; // 0 Idle,1 Scanning,2 Complete,3 Cancelled,4 Error + in property total-files: 0; + in property processed-files: 0; + in property progress: 0.0; // 0..1 + in property current-file: ""; // basename, or the cleanup label + in property error-count: 0; +} + export global LibraryManageActions { callback load(); // (re)load the folder list (panel init) callback add-folder(); // native dir picker (Rust)