max / audiofiles
58 files changed,
+1829 insertions,
-640 deletions
| @@ -429,6 +429,7 @@ dependencies = [ | |||
| 429 | 429 | "egui", | |
| 430 | 430 | "egui_extras", | |
| 431 | 431 | "hound", | |
| 432 | + | "libc", | |
| 432 | 433 | "objc2 0.6.3", | |
| 433 | 434 | "objc2-app-kit 0.3.2", | |
| 434 | 435 | "objc2-foundation 0.3.2", |
| @@ -44,6 +44,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat | |||
| 44 | 44 | semver = "1" | |
| 45 | 45 | open = "5" | |
| 46 | 46 | rayon = "1.10" | |
| 47 | + | libc = "0.2" | |
| 47 | 48 | midir = "0.10" | |
| 48 | 49 | docengine = { path = "../../MNW/shared/docengine" } | |
| 49 | 50 | tagtree = { path = "../../MNW/shared/tagtree" } |
| @@ -26,6 +26,9 @@ rusqlite = { workspace = true } | |||
| 26 | 26 | tracing = { workspace = true } | |
| 27 | 27 | theme-common = { workspace = true } | |
| 28 | 28 | ||
| 29 | + | [target.'cfg(unix)'.dependencies] | |
| 30 | + | libc = { workspace = true } | |
| 31 | + | ||
| 29 | 32 | [dev-dependencies] | |
| 30 | 33 | tempfile = "3.25.0" | |
| 31 | 34 |
| @@ -18,10 +18,9 @@ use audiofiles_core::export::profile::DeviceProfileSummary; | |||
| 18 | 18 | use audiofiles_core::export::ExportItem; | |
| 19 | 19 | use audiofiles_core::search::SearchFilter; | |
| 20 | 20 | use audiofiles_core::collections::Collection; | |
| 21 | - | use audiofiles_core::smart_folders::SmartFolder; | |
| 22 | 21 | use audiofiles_core::store::SampleStore; | |
| 23 | 22 | use audiofiles_core::vfs::{self, Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 24 | - | use audiofiles_core::{collections, fingerprint, search, similarity, smart_folders, tags, CollectionId, NodeId, SmartFolderId, VfsId}; | |
| 23 | + | use audiofiles_core::{collections, fingerprint, search, similarity, tags, CollectionId, NodeId, VfsId}; | |
| 25 | 24 | use parking_lot::Mutex; | |
| 26 | 25 | ||
| 27 | 26 | use super::{ | |
| @@ -232,7 +231,12 @@ fn build_sample_info( | |||
| 232 | 231 | )) | |
| 233 | 232 | }, | |
| 234 | 233 | ) | |
| 235 | - | .unwrap_or((0, 0, item.duration.unwrap_or(0.0))); | |
| 234 | + | .unwrap_or_else(|e| { | |
| 235 | + | if !matches!(e, rusqlite::Error::QueryReturnedNoRows) { | |
| 236 | + | tracing::warn!("Failed to query audio_analysis for {}: {e}", &item.hash[..8]); | |
| 237 | + | } | |
| 238 | + | (0, 0, item.duration.unwrap_or(0.0)) | |
| 239 | + | }); | |
| 236 | 240 | ||
| 237 | 241 | // Query samples for file_size | |
| 238 | 242 | let file_size = db | |
| @@ -457,33 +461,6 @@ impl Backend for DirectBackend { | |||
| 457 | 461 | Ok(search::search_global(&db, filter)?) | |
| 458 | 462 | } | |
| 459 | 463 | ||
| 460 | - | // --- Smart folders --- | |
| 461 | - | ||
| 462 | - | fn list_smart_folders(&self, vfs_id: VfsId) -> BackendResult<Vec<SmartFolder>> { | |
| 463 | - | let db = self.db.lock(); | |
| 464 | - | Ok(smart_folders::list_smart_folders(&db, vfs_id)?) | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | fn create_smart_folder( | |
| 468 | - | &self, | |
| 469 | - | vfs_id: VfsId, | |
| 470 | - | name: &str, | |
| 471 | - | filter: &SearchFilter, | |
| 472 | - | ) -> BackendResult<SmartFolderId> { | |
| 473 | - | let db = self.db.lock(); | |
| 474 | - | Ok(smart_folders::create_smart_folder(&db, vfs_id, name, filter)?) | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | fn delete_smart_folder(&self, id: SmartFolderId) -> BackendResult<()> { | |
| 478 | - | let db = self.db.lock(); | |
| 479 | - | Ok(smart_folders::delete_smart_folder(&db, id)?) | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | fn rename_smart_folder(&self, id: SmartFolderId, new_name: &str) -> BackendResult<()> { | |
| 483 | - | let db = self.db.lock(); | |
| 484 | - | Ok(smart_folders::rename_smart_folder(&db, id, new_name)?) | |
| 485 | - | } | |
| 486 | - | ||
| 487 | 464 | // --- Collections --- | |
| 488 | 465 | ||
| 489 | 466 | fn list_collections(&self) -> BackendResult<Vec<Collection>> { | |
| @@ -496,6 +473,11 @@ impl Backend for DirectBackend { | |||
| 496 | 473 | Ok(collections::create_collection(&db, name, description)?) | |
| 497 | 474 | } | |
| 498 | 475 | ||
| 476 | + | fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult<CollectionId> { | |
| 477 | + | let db = self.db.lock(); | |
| 478 | + | Ok(collections::create_dynamic_collection(&db, name, filter)?) | |
| 479 | + | } | |
| 480 | + | ||
| 499 | 481 | fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()> { | |
| 500 | 482 | let db = self.db.lock(); | |
| 501 | 483 | Ok(collections::rename_collection(&db, id, new_name)?) | |
| @@ -871,6 +853,11 @@ impl Backend for DirectBackend { | |||
| 871 | 853 | } | |
| 872 | 854 | ||
| 873 | 855 | fn start_cleanup(&self) -> BackendResult<()> { | |
| 856 | + | // Cancel any existing cleanup worker to prevent concurrent cleanups | |
| 857 | + | if let Some(worker) = self.cleanup_worker.lock().take() { | |
| 858 | + | worker.send(CleanupCommand::Cancel); | |
| 859 | + | } | |
| 860 | + | ||
| 874 | 861 | let db_path = self.data_dir.join("audiofiles.db"); | |
| 875 | 862 | let store_root = self.store.root().to_path_buf(); | |
| 876 | 863 | ||
| @@ -1160,25 +1147,16 @@ mod tests { | |||
| 1160 | 1147 | } | |
| 1161 | 1148 | ||
| 1162 | 1149 | #[test] | |
| 1163 | - | fn smart_folder_crud() { | |
| 1150 | + | fn dynamic_collection_crud() { | |
| 1164 | 1151 | let backend = setup(); | |
| 1165 | - | let vfs_id = backend.create_vfs("Test").unwrap(); | |
| 1166 | 1152 | let filter = SearchFilter::default(); | |
| 1167 | 1153 | ||
| 1168 | - | let sf_id = backend.create_smart_folder(vfs_id, "Kicks", &filter).unwrap(); | |
| 1169 | - | assert!(sf_id.as_i64() > 0); | |
| 1170 | - | ||
| 1171 | - | let folders = backend.list_smart_folders(vfs_id).unwrap(); | |
| 1172 | - | assert_eq!(folders.len(), 1); | |
| 1173 | - | assert_eq!(folders[0].name, "Kicks"); | |
| 1174 | - | ||
| 1175 | - | backend.rename_smart_folder(sf_id, "Fast Kicks").unwrap(); | |
| 1176 | - | let folders = backend.list_smart_folders(vfs_id).unwrap(); | |
| 1177 | - | assert_eq!(folders[0].name, "Fast Kicks"); | |
| 1154 | + | let id = backend.create_dynamic_collection("Kicks", &filter).unwrap(); | |
| 1155 | + | assert!(id.as_i64() > 0); | |
| 1178 | 1156 | ||
| 1179 | - | backend.delete_smart_folder(sf_id).unwrap(); | |
| 1180 | - | let folders = backend.list_smart_folders(vfs_id).unwrap(); | |
| 1181 | - | assert!(folders.is_empty()); | |
| 1157 | + | let colls = backend.list_collections().unwrap(); | |
| 1158 | + | let dynamic = colls.iter().find(|c| c.name == "Kicks").unwrap(); | |
| 1159 | + | assert!(dynamic.is_dynamic()); | |
| 1182 | 1160 | } | |
| 1183 | 1161 | ||
| 1184 | 1162 | #[test] |
| @@ -20,9 +20,8 @@ use audiofiles_core::export::profile::DeviceProfileSummary; | |||
| 20 | 20 | use audiofiles_core::export::{ExportConfig, ExportItem}; | |
| 21 | 21 | use audiofiles_core::search::SearchFilter; | |
| 22 | 22 | use audiofiles_core::collections::Collection; | |
| 23 | - | use audiofiles_core::smart_folders::SmartFolder; | |
| 24 | 23 | use audiofiles_core::vfs::{Vfs, VfsNode, VfsNodeWithAnalysis}; | |
| 25 | - | use audiofiles_core::{CollectionId, NodeId, SmartFolderId, VfsId}; | |
| 24 | + | use audiofiles_core::{CollectionId, NodeId, VfsId}; | |
| 26 | 25 | ||
| 27 | 26 | pub use direct::DirectBackend; | |
| 28 | 27 | ||
| @@ -265,31 +264,17 @@ pub trait Backend: Send + Sync { | |||
| 265 | 264 | ||
| 266 | 265 | // --- Smart folders --- | |
| 267 | 266 | ||
| 268 | - | /// List smart folders for a VFS. | |
| 269 | - | fn list_smart_folders(&self, vfs_id: VfsId) -> BackendResult<Vec<SmartFolder>>; | |
| 270 | - | ||
| 271 | - | /// Create a smart folder. Returns the new ID. | |
| 272 | - | fn create_smart_folder( | |
| 273 | - | &self, | |
| 274 | - | vfs_id: VfsId, | |
| 275 | - | name: &str, | |
| 276 | - | filter: &SearchFilter, | |
| 277 | - | ) -> BackendResult<SmartFolderId>; | |
| 278 | - | ||
| 279 | - | /// Delete a smart folder. | |
| 280 | - | fn delete_smart_folder(&self, id: SmartFolderId) -> BackendResult<()>; | |
| 281 | - | ||
| 282 | - | /// Rename a smart folder. | |
| 283 | - | fn rename_smart_folder(&self, id: SmartFolderId, new_name: &str) -> BackendResult<()>; | |
| 284 | - | ||
| 285 | 267 | // --- Collections --- | |
| 286 | 268 | ||
| 287 | 269 | /// List all collections with member counts. | |
| 288 | 270 | fn list_collections(&self) -> BackendResult<Vec<Collection>>; | |
| 289 | 271 | ||
| 290 | - | /// Create a collection. Returns the new ID. | |
| 272 | + | /// Create a manual collection. Returns the new ID. | |
| 291 | 273 | fn create_collection(&self, name: &str, description: Option<&str>) -> BackendResult<CollectionId>; | |
| 292 | 274 | ||
| 275 | + | /// Create a dynamic collection (saved search). Returns the new ID. | |
| 276 | + | fn create_dynamic_collection(&self, name: &str, filter: &SearchFilter) -> BackendResult<CollectionId>; | |
| 277 | + | ||
| 293 | 278 | /// Rename a collection. | |
| 294 | 279 | fn rename_collection(&self, id: CollectionId, new_name: &str) -> BackendResult<()>; | |
| 295 | 280 |
| @@ -339,8 +339,32 @@ fn handle_keyboard(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 339 | 339 | state.sidebar_visible = !state.sidebar_visible; | |
| 340 | 340 | } | |
| 341 | 341 | // "D" toggles detail panel | |
| 342 | - | if input.key_pressed(egui::Key::D) { | |
| 342 | + | if input.key_pressed(egui::Key::D) && !shift { | |
| 343 | 343 | state.detail_visible = !state.detail_visible; | |
| 344 | 344 | } | |
| 345 | + | // Shift+F: find similar | |
| 346 | + | if shift && input.key_pressed(egui::Key::F) { | |
| 347 | + | if let Some(node) = state.selected_node() { | |
| 348 | + | if let Some(hash) = &node.node.sample_hash { | |
| 349 | + | let hash = hash.clone(); | |
| 350 | + | state.find_similar(&hash); | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | // Shift+D: find duplicates | |
| 355 | + | if shift && input.key_pressed(egui::Key::D) { | |
| 356 | + | if let Some(node) = state.selected_node() { | |
| 357 | + | if let Some(hash) = &node.node.sample_hash { | |
| 358 | + | let hash = hash.clone(); | |
| 359 | + | state.find_near_duplicates(&hash); | |
| 360 | + | } | |
| 361 | + | } | |
| 362 | + | } | |
| 363 | + | // Cmd+M: bulk move | |
| 364 | + | if input.modifiers.command && input.key_pressed(egui::Key::M) { | |
| 365 | + | if state.selection.count() > 1 { | |
| 366 | + | state.open_bulk_move_modal(); | |
| 367 | + | } | |
| 368 | + | } | |
| 345 | 369 | }); | |
| 346 | 370 | } |
| @@ -247,6 +247,11 @@ pub fn render_voices( | |||
| 247 | 247 | continue; | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 250 | + | if voice.zone_index >= inst.zone_buffers.len() { | |
| 251 | + | voice.active = false; | |
| 252 | + | voice.envelope_phase = EnvelopePhase::Idle; | |
| 253 | + | continue; | |
| 254 | + | } | |
| 250 | 255 | let zone = &inst.zone_buffers[voice.zone_index]; | |
| 251 | 256 | let total_frames = zone.buffer.data.len() / 2; // stereo interleaved | |
| 252 | 257 | if total_frames == 0 { |
| @@ -294,7 +294,12 @@ pub fn start_streaming_decode( | |||
| 294 | 294 | ||
| 295 | 295 | // Set up the initial buffer and playback state (not yet playing) | |
| 296 | 296 | { | |
| 297 | - | let capacity = n_frames_estimate.unwrap_or(source_sample_rate as usize * 60) * 2; | |
| 297 | + | // Cap capacity to prevent OOM from malformed codec metadata (max ~30 min stereo) | |
| 298 | + | let max_frames = source_sample_rate as usize * 60 * 30; | |
| 299 | + | let capacity = n_frames_estimate | |
| 300 | + | .map(|n| n.min(max_frames)) | |
| 301 | + | .unwrap_or(source_sample_rate as usize * 60) | |
| 302 | + | * 2; | |
| 298 | 303 | let mut guard = shared.preview.lock(); | |
| 299 | 304 | guard.buffer = Some(PreviewBuffer { | |
| 300 | 305 | data: Vec::with_capacity(capacity), |
| @@ -1,7 +1,28 @@ | |||
| 1 | + | use std::path::Path; | |
| 2 | + | ||
| 1 | 3 | use tracing::{error, warn}; | |
| 2 | 4 | ||
| 3 | 5 | use super::*; | |
| 4 | 6 | ||
| 7 | + | /// Count audio files in a directory (recursive). Used for the import dry-run preview. | |
| 8 | + | fn count_audio_files(dir: &Path) -> usize { | |
| 9 | + | let mut count = 0; | |
| 10 | + | let mut dirs = vec![dir.to_path_buf()]; | |
| 11 | + | while let Some(d) = dirs.pop() { | |
| 12 | + | if let Ok(entries) = std::fs::read_dir(&d) { | |
| 13 | + | for entry in entries.flatten() { | |
| 14 | + | let path = entry.path(); | |
| 15 | + | if path.is_dir() { | |
| 16 | + | dirs.push(path); | |
| 17 | + | } else if audiofiles_core::util::is_audio_file(&path) { | |
| 18 | + | count += 1; | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | count | |
| 24 | + | } | |
| 25 | + | ||
| 5 | 26 | impl BrowserState { | |
| 6 | 27 | /// Quick-import a single file or directory via drag-and-drop into the current folder. | |
| 7 | 28 | /// After importing, starts the analysis flow so columns (BPM, Key, etc.) get populated. | |
| @@ -189,6 +210,19 @@ impl BrowserState { | |||
| 189 | 210 | ||
| 190 | 211 | // --- Folder import --- | |
| 191 | 212 | ||
| 213 | + | /// Quick import: choose folder → import as new vault → analyze with defaults. | |
| 214 | + | /// Skips ConfigureImport, TagFolders, and ConfigureAnalysis screens. | |
| 215 | + | pub fn quick_import_folder(&mut self, source: PathBuf) { | |
| 216 | + | let vfs_name = source | |
| 217 | + | .file_name() | |
| 218 | + | .and_then(|n| n.to_str()) | |
| 219 | + | .unwrap_or("folder") | |
| 220 | + | .to_string(); | |
| 221 | + | self.quick_import = true; | |
| 222 | + | let strategy = ImportStrategy::NewVfs { vfs_name }; | |
| 223 | + | self.start_folder_import(source, strategy); | |
| 224 | + | } | |
| 225 | + | ||
| 192 | 226 | /// Open the import configuration modal for a dropped or selected folder. | |
| 193 | 227 | pub fn show_import_options(&mut self, source: PathBuf) { | |
| 194 | 228 | let source_name = source | |
| @@ -200,6 +234,10 @@ impl BrowserState { | |||
| 200 | 234 | warn!("Failed to list VFS: {e}"); | |
| 201 | 235 | Vec::new() | |
| 202 | 236 | }); | |
| 237 | + | ||
| 238 | + | // Dry-run: count audio files in the source folder | |
| 239 | + | let audio_file_count = count_audio_files(&source); | |
| 240 | + | ||
| 203 | 241 | self.import_mode = ImportMode::ConfigureImport { | |
| 204 | 242 | source, | |
| 205 | 243 | source_name: source_name.clone(), | |
| @@ -209,6 +247,7 @@ impl BrowserState { | |||
| 209 | 247 | available_vfs, | |
| 210 | 248 | selected_merge_vfs_idx: 0, | |
| 211 | 249 | new_vfs_name: source_name, | |
| 250 | + | audio_file_count, | |
| 212 | 251 | }; | |
| 213 | 252 | } | |
| 214 | 253 | ||
| @@ -313,7 +352,7 @@ impl BrowserState { | |||
| 313 | 352 | } | |
| 314 | 353 | self.status = parts.join(", "); | |
| 315 | 354 | ||
| 316 | - | if !folders.is_empty() { | |
| 355 | + | if !folders.is_empty() && !self.quick_import { | |
| 317 | 356 | let entries = folders | |
| 318 | 357 | .into_iter() | |
| 319 | 358 | .map(|f| FolderTagEntry { | |
| @@ -329,10 +368,16 @@ impl BrowserState { | |||
| 329 | 368 | sample_hashes: imported, | |
| 330 | 369 | }; | |
| 331 | 370 | } else if !imported.is_empty() { | |
| 332 | - | self.start_analysis_flow(imported); | |
| 371 | + | if self.quick_import { | |
| 372 | + | // Skip ConfigureAnalysis — run with defaults immediately | |
| 373 | + | self.run_analysis(imported, AnalysisConfig::default()); | |
| 374 | + | } else { | |
| 375 | + | self.start_analysis_flow(imported); | |
| 376 | + | } | |
| 333 | 377 | } else if self.has_import_errors() { | |
| 334 | 378 | self.import_mode = ImportMode::ReviewErrors; | |
| 335 | 379 | } else { | |
| 380 | + | self.quick_import = false; | |
| 336 | 381 | self.import_mode = ImportMode::None; | |
| 337 | 382 | } | |
| 338 | 383 | return true; | |
| @@ -381,7 +426,20 @@ impl BrowserState { | |||
| 381 | 426 | } | |
| 382 | 427 | BackendEvent::AnalysisBatchComplete => { | |
| 383 | 428 | let items = std::mem::take(&mut self.pending_review_items); | |
| 384 | - | if items.is_empty() { | |
| 429 | + | if self.quick_import { | |
| 430 | + | // Auto-accept all suggestions in quick mode | |
| 431 | + | for item in &items { | |
| 432 | + | for s in &item.suggestions { | |
| 433 | + | let _ = self.backend.add_tag(&item.hash, &s.suggestion.tag); | |
| 434 | + | } | |
| 435 | + | } | |
| 436 | + | self.quick_import = false; | |
| 437 | + | self.refresh_contents(); | |
| 438 | + | self.refresh_vfs_list(); | |
| 439 | + | let count = items.len(); | |
| 440 | + | self.import_mode = ImportMode::None; | |
| 441 | + | self.status = format!("Quick import complete: {count} samples analyzed"); | |
| 442 | + | } else if items.is_empty() { | |
| 385 | 443 | if self.has_import_errors() { | |
| 386 | 444 | self.import_mode = ImportMode::ReviewErrors; | |
| 387 | 445 | } else { | |
| @@ -852,6 +910,99 @@ impl BrowserState { | |||
| 852 | 910 | } | |
| 853 | 911 | } | |
| 854 | 912 | ||
| 913 | + | /// Insert silence at the configured position and duration. | |
| 914 | + | pub fn apply_edit_insert_silence(&mut self) { | |
| 915 | + | let hash = match &self.edit.hash { | |
| 916 | + | Some(h) => h.clone(), | |
| 917 | + | None => return, | |
| 918 | + | }; | |
| 919 | + | let sample_rate = self.backend.get_analysis(&hash).ok().flatten() | |
| 920 | + | .map(|a| a.sample_rate) | |
| 921 | + | .unwrap_or(44100); | |
| 922 | + | let start_frame = ((self.edit.silence_position_ms / 1000.0) * sample_rate as f64) as usize; | |
| 923 | + | let duration_frames = ((self.edit.silence_duration_ms / 1000.0) * sample_rate as f64) as usize; | |
| 924 | + | let op = audiofiles_core::edit::EditOperation::InsertSilence { start_frame, duration_frames }; | |
| 925 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 926 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 927 | + | self.status = format!("Edit failed: {e}"); | |
| 928 | + | } | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | /// Remove a range of frames between the configured start and end positions. | |
| 932 | + | pub fn apply_edit_remove_range(&mut self) { | |
| 933 | + | let hash = match &self.edit.hash { | |
| 934 | + | Some(h) => h.clone(), | |
| 935 | + | None => return, | |
| 936 | + | }; | |
| 937 | + | let sample_rate = self.backend.get_analysis(&hash).ok().flatten() | |
| 938 | + | .map(|a| a.sample_rate) | |
| 939 | + | .unwrap_or(44100); | |
| 940 | + | let start_frame = ((self.edit.remove_start_ms / 1000.0) * sample_rate as f64) as usize; | |
| 941 | + | let end_frame = ((self.edit.remove_end_ms / 1000.0) * sample_rate as f64) as usize; | |
| 942 | + | if start_frame >= end_frame { | |
| 943 | + | self.status = "Remove range: start must be before end".to_string(); | |
| 944 | + | return; | |
| 945 | + | } | |
| 946 | + | let op = audiofiles_core::edit::EditOperation::RemoveRange { start_frame, end_frame }; | |
| 947 | + | let ext = self.backend.sample_extension(&hash).unwrap_or_default(); | |
| 948 | + | if let Err(e) = self.backend.start_edit(&hash, &ext, op) { | |
| 949 | + | self.status = format!("Edit failed: {e}"); | |
| 950 | + | } | |
| 951 | + | } | |
| 952 | + | ||
| 953 | + | /// Apply an edit operation to all selected samples (batch edit). | |
| 954 | + | /// Skips directories and samples without hashes. | |
| 955 | + | pub fn batch_edit(&mut self, op_factory: impl Fn(&str) -> Option<audiofiles_core::edit::EditOperation>) { | |
| 956 | + | let hashes = self.selected_sample_hashes(); | |
| 957 | + | if hashes.is_empty() { | |
| 958 | + | self.status = "No samples selected for batch edit".to_string(); | |
| 959 | + | return; | |
| 960 | + | } | |
| 961 | + | let mut applied = 0; | |
| 962 | + | let mut errors = 0; | |
| 963 | + | for hash in &hashes { | |
| 964 | + | let Some(op) = op_factory(hash) else { continue; }; | |
| 965 | + | let ext = self.backend.sample_extension(hash).unwrap_or_default(); | |
| 966 | + | match self.backend.start_edit(hash, &ext, op) { | |
| 967 | + | Ok(()) => applied += 1, | |
| 968 | + | Err(_) => errors += 1, | |
| 969 | + | } | |
| 970 | + | } | |
| 971 | + | self.status = if errors > 0 { | |
| 972 | + | format!("Batch edit: {applied} applied, {errors} failed") | |
| 973 | + | } else { | |
| 974 | + | format!("Batch edit: {applied} samples processed") | |
| 975 | + | }; | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | /// Batch normalize (peak) all selected samples. | |
| 979 | + | pub fn batch_normalize_peak(&mut self, target_db: f64) { | |
| 980 | + | self.batch_edit(|_hash| { | |
| 981 | + | Some(audiofiles_core::edit::EditOperation::NormalizePeak { target_db }) | |
| 982 | + | }); | |
| 983 | + | } | |
| 984 | + | ||
| 985 | + | /// Batch normalize (LUFS) all selected samples. | |
| 986 | + | pub fn batch_normalize_lufs(&mut self, target_lufs: f64) { | |
| 987 | + | self.batch_edit(|_hash| { | |
| 988 | + | Some(audiofiles_core::edit::EditOperation::NormalizeLufs { target_lufs }) | |
| 989 | + | }); | |
| 990 | + | } | |
| 991 | + | ||
| 992 | + | /// Batch reverse all selected samples. | |
| 993 | + | pub fn batch_reverse(&mut self) { | |
| 994 | + | self.batch_edit(|_hash| { | |
| 995 | + | Some(audiofiles_core::edit::EditOperation::Reverse) | |
| 996 | + | }); | |
| 997 | + | } | |
| 998 | + | ||
| 999 | + | /// Batch apply gain to all selected samples. | |
| 1000 | + | pub fn batch_gain(&mut self, db: f64) { | |
| 1001 | + | self.batch_edit(|_hash| { | |
| 1002 | + | Some(audiofiles_core::edit::EditOperation::Gain { db }) | |
| 1003 | + | }); | |
| 1004 | + | } | |
| 1005 | + | ||
| 855 | 1006 | /// Save the user's preferred edit result mode. | |
| 856 | 1007 | pub fn set_edit_result_mode(&mut self, mode: super::EditResultMode) { | |
| 857 | 1008 | let mode_str = match mode { |
| @@ -29,7 +29,7 @@ impl BrowserState { | |||
| 29 | 29 | self.breadcrumb.clear(); | |
| 30 | 30 | self.selection.clear(); | |
| 31 | 31 | self.refresh_contents(); | |
| 32 | - | self.refresh_smart_folders(); | |
| 32 | + | self.refresh_collections(); | |
| 33 | 33 | self.check_unsafe_integrity(); | |
| 34 | 34 | } | |
| 35 | 35 | ||
| @@ -74,6 +74,12 @@ impl BrowserState { | |||
| 74 | 74 | self.show_unsafe_warning = false; | |
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | + | /// Dismiss the first-launch hint and persist the preference. | |
| 78 | + | pub fn dismiss_first_launch_hint(&mut self) { | |
| 79 | + | self.show_first_launch_hint = false; | |
| 80 | + | let _ = self.backend.set_config("hints_dismissed", "1"); | |
| 81 | + | } | |
| 82 | + | ||
| 77 | 83 | /// Refresh the cached list of all tags. | |
| 78 | 84 | pub fn refresh_all_tags(&mut self) { | |
| 79 | 85 | self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| { | |
| @@ -82,53 +88,27 @@ impl BrowserState { | |||
| 82 | 88 | })); | |
| 83 | 89 | } | |
| 84 | 90 | ||
| 85 | - | // --- Smart folders --- | |
| 86 | - | ||
| 87 | - | /// Refresh the smart folder list for the current VFS. | |
| 88 | - | pub fn refresh_smart_folders(&mut self) { | |
| 89 | - | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 90 | - | self.smart_folders = self.backend.list_smart_folders(vfs_id) | |
| 91 | - | .unwrap_or_else(|e| { | |
| 92 | - | warn!("Failed to load smart folders: {e}"); | |
| 93 | - | Vec::new() | |
| 94 | - | }); | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | /// Save the current search filter as a smart folder with the given name. | |
| 98 | - | pub fn save_smart_folder(&mut self, name: &str) { | |
| 99 | - | let Some(vfs_id) = self.current_vfs_id() else { return }; | |
| 100 | - | match self.backend.create_smart_folder(vfs_id, name, &self.search_filter) { | |
| 91 | + | /// Save the current search filter as a dynamic collection with the given name. | |
| 92 | + | pub fn save_dynamic_collection(&mut self, name: &str) { | |
| 93 | + | match self.backend.create_dynamic_collection(name, &self.search_filter) { | |
| 101 | 94 | Ok(_) => { | |
| 102 | - | self.status = format!("Saved smart folder: {name}"); | |
| 95 | + | self.status = format!("Saved collection: {name}"); | |
| 103 | 96 | } | |
| 104 | 97 | Err(e) => { | |
| 105 | - | self.status = format!("Failed to save smart folder: {e}"); | |
| 98 | + | self.status = format!("Failed to save collection: {e}"); | |
| 106 | 99 | } | |
| 107 | 100 | } | |
| 108 | - | self.refresh_smart_folders(); | |
| 101 | + | self.refresh_collections(); | |
| 109 | 102 | } | |
| 110 | 103 | ||
| 111 | - | /// Activate a smart folder by index: apply its filter and refresh. | |
| 112 | - | pub fn activate_smart_folder(&mut self, idx: usize) { | |
| 113 | - | if let Some(folder) = self.smart_folders.get(idx) { | |
| 114 | - | self.search_filter = folder.filter.clone(); | |
| 115 | - | self.search_query = folder.filter.text_query.clone(); | |
| 116 | - | self.similarity_search_hash = None; | |
| 117 | - | self.selection.clear(); | |
| 118 | - | self.refresh_contents(); | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | /// Delete a smart folder by index. | |
| 123 | - | pub fn delete_smart_folder(&mut self, idx: usize) { | |
| 124 | - | if let Some(folder) = self.smart_folders.get(idx) { | |
| 125 | - | if let Err(e) = self.backend.delete_smart_folder(folder.id) { | |
| 126 | - | self.status = format!("Failed to delete smart folder: {e}"); | |
| 127 | - | return; | |
| 128 | - | } | |
| 129 | - | self.refresh_smart_folders(); | |
| 130 | - | self.status = "Smart folder deleted".to_string(); | |
| 131 | - | } | |
| 104 | + | /// Activate a dynamic collection: apply its filter and refresh. | |
| 105 | + | pub fn activate_dynamic_collection(&mut self, _id: CollectionId, filter: &SearchFilter) { | |
| 106 | + | self.active_collection = None; // not a manual-collection view | |
| 107 | + | self.search_filter = filter.clone(); | |
| 108 | + | self.search_query = filter.text_query.clone(); | |
| 109 | + | self.similarity_search_hash = None; | |
| 110 | + | self.selection.clear(); | |
| 111 | + | self.refresh_contents(); | |
| 132 | 112 | } | |
| 133 | 113 | ||
| 134 | 114 | // --- Collections --- |
| @@ -18,7 +18,6 @@ use audiofiles_core::db::Database; | |||
| 18 | 18 | use audiofiles_core::error::CoreError; | |
| 19 | 19 | use audiofiles_core::collections::Collection; | |
| 20 | 20 | use audiofiles_core::search::SearchFilter; | |
| 21 | - | use audiofiles_core::smart_folders::SmartFolder; | |
| 22 | 21 | use audiofiles_core::store::SampleStore; | |
| 23 | 22 | use audiofiles_core::util::split_name_ext; | |
| 24 | 23 | use audiofiles_core::vfs::{NodeType, Vfs, VfsNode}; | |
| @@ -111,15 +110,16 @@ pub struct BrowserState { | |||
| 111 | 110 | pub search_filter: SearchFilter, | |
| 112 | 111 | pub filter_panel_open: bool, | |
| 113 | 112 | ||
| 114 | - | // Smart folders | |
| 115 | - | pub smart_folders: Vec<SmartFolder>, | |
| 116 | - | pub smart_folder_name_input: String, | |
| 113 | + | // Dynamic collection (saved search) name input | |
| 114 | + | pub collection_filter_name_input: String, | |
| 117 | 115 | ||
| 118 | 116 | // Similarity search | |
| 119 | 117 | pub similarity_search_hash: Option<String>, | |
| 120 | 118 | ||
| 121 | 119 | // Tags cache | |
| 122 | 120 | pub all_tags: Arc<Vec<String>>, | |
| 121 | + | /// Sidebar tag tree filter input. | |
| 122 | + | pub tag_search: String, | |
| 123 | 123 | ||
| 124 | 124 | // Preview | |
| 125 | 125 | pub previewing_hash: Option<String>, | |
| @@ -145,6 +145,8 @@ pub struct BrowserState { | |||
| 145 | 145 | ||
| 146 | 146 | // Overlays | |
| 147 | 147 | pub show_help: bool, | |
| 148 | + | /// Help overlay tab: 0 = Shortcuts, 1 = Features. | |
| 149 | + | pub help_tab: u8, | |
| 148 | 150 | pub pending_confirm: Option<ConfirmAction>, | |
| 149 | 151 | ||
| 150 | 152 | // VFS management modals | |
| @@ -162,6 +164,8 @@ pub struct BrowserState { | |||
| 162 | 164 | ||
| 163 | 165 | // Analysis | |
| 164 | 166 | pub import_mode: ImportMode, | |
| 167 | + | /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis. | |
| 168 | + | pub quick_import: bool, | |
| 165 | 169 | pub pending_review_items: Vec<ReviewItem>, | |
| 166 | 170 | ||
| 167 | 171 | // Error accumulation for import/analysis workflows | |
| @@ -197,6 +201,8 @@ pub struct BrowserState { | |||
| 197 | 201 | ||
| 198 | 202 | // First-run onboarding | |
| 199 | 203 | pub show_vfs_banner: bool, | |
| 204 | + | /// Show "Right-click for options · F1 for shortcuts" hint until dismissed. | |
| 205 | + | pub show_first_launch_hint: bool, | |
| 200 | 206 | ||
| 201 | 207 | // Drag-out | |
| 202 | 208 | /// Set when an OS drag fires; prevents re-triggering until the pointer is | |
| @@ -255,7 +261,7 @@ impl BrowserState { | |||
| 255 | 261 | ) -> Result<Self, Box<dyn std::error::Error>> { | |
| 256 | 262 | let mut vfs_list = backend.list_vfs()?; | |
| 257 | 263 | if vfs_list.is_empty() { | |
| 258 | - | backend.create_vfs("Library")?; | |
| 264 | + | backend.create_vfs("Vault")?; | |
| 259 | 265 | vfs_list = backend.list_vfs()?; | |
| 260 | 266 | } | |
| 261 | 267 | ||
| @@ -263,8 +269,6 @@ impl BrowserState { | |||
| 263 | 269 | .unwrap_or_else(|e| { error!("Failed to load initial contents: {e}"); Vec::new() }); | |
| 264 | 270 | let all_tags = backend.list_all_tags() | |
| 265 | 271 | .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() }); | |
| 266 | - | let smart_folders_list = backend.list_smart_folders(vfs_list[0].id) | |
| 267 | - | .unwrap_or_else(|e| { warn!("Failed to load smart folders: {e}"); Vec::new() }); | |
| 268 | 272 | let collections_list = backend.list_collections() | |
| 269 | 273 | .unwrap_or_else(|e| { warn!("Failed to load collections: {e}"); Vec::new() }); | |
| 270 | 274 | ||
| @@ -281,6 +285,7 @@ impl BrowserState { | |||
| 281 | 285 | ||
| 282 | 286 | // First-run VFS banner | |
| 283 | 287 | let vfs_explained = backend.get_config("vfs_explained").ok().flatten().as_deref() == Some("1"); | |
| 288 | + | let hints_dismissed = backend.get_config("hints_dismissed").ok().flatten().as_deref() == Some("1"); | |
| 284 | 289 | ||
| 285 | 290 | // Load display density | |
| 286 | 291 | let row_height = backend.get_config("row_height").ok().flatten() | |
| @@ -322,10 +327,10 @@ impl BrowserState { | |||
| 322 | 327 | search_query: String::new(), | |
| 323 | 328 | search_filter: SearchFilter::default(), | |
| 324 | 329 | filter_panel_open: false, | |
| 325 | - | smart_folders: smart_folders_list, | |
| 326 | - | smart_folder_name_input: String::new(), | |
| 330 | + | collection_filter_name_input: String::new(), | |
| 327 | 331 | similarity_search_hash: None, | |
| 328 | 332 | all_tags: Arc::new(all_tags), | |
| 333 | + | tag_search: String::new(), | |
| 329 | 334 | previewing_hash: None, | |
| 330 | 335 | shared, | |
| 331 | 336 | sample_rate, | |
| @@ -339,6 +344,7 @@ impl BrowserState { | |||
| 339 | 344 | midi_state: MidiUiState::default(), | |
| 340 | 345 | midi_pending_action: None, | |
| 341 | 346 | show_help: false, | |
| 347 | + | help_tab: 0, | |
| 342 | 348 | pending_confirm: None, | |
| 343 | 349 | vfs_create_input: String::new(), | |
| 344 | 350 | vfs_rename_target: None, | |
| @@ -350,6 +356,7 @@ impl BrowserState { | |||
| 350 | 356 | bulk_modal: None, | |
| 351 | 357 | column_config: ColumnConfig::default(), | |
| 352 | 358 | import_mode: ImportMode::None, | |
| 359 | + | quick_import: false, | |
| 353 | 360 | pending_review_items: Vec::new(), | |
| 354 | 361 | import_file_errors: Vec::new(), | |
| 355 | 362 | analysis_errors: Vec::new(), | |
| @@ -368,6 +375,7 @@ impl BrowserState { | |||
| 368 | 375 | edit: EditUiState::default(), | |
| 369 | 376 | row_height, | |
| 370 | 377 | show_vfs_banner: !vfs_explained, | |
| 378 | + | show_first_launch_hint: !hints_dismissed, | |
| 371 | 379 | os_drag_cooldown: None, | |
| 372 | 380 | mirror_enabled, | |
| 373 | 381 | mirror_path, |
| @@ -248,7 +248,7 @@ impl BrowserState { | |||
| 248 | 248 | self.selection.clear(); | |
| 249 | 249 | self.similarity_search_hash = None; | |
| 250 | 250 | self.refresh_contents(); | |
| 251 | - | self.refresh_smart_folders(); | |
| 251 | + | self.refresh_collections(); | |
| 252 | 252 | self.status = format!("Switched to: {}", self.vfs_list[self.current_vfs_idx].name); | |
| 253 | 253 | } | |
| 254 | 254 | } |
| @@ -6,7 +6,7 @@ use audiofiles_core::vfs::NodeType; | |||
| 6 | 6 | fn make_state() -> (BrowserState, tempfile::TempDir) { | |
| 7 | 7 | let dir = tempfile::TempDir::new().unwrap(); | |
| 8 | 8 | let shared = Arc::new(SharedState::new()); | |
| 9 | - | let state = BrowserState::new(dir.path(), shared, 44100.0, "Library").unwrap(); | |
| 9 | + | let state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); | |
| 10 | 10 | (state, dir) | |
| 11 | 11 | } | |
| 12 | 12 | ||
| @@ -1046,7 +1046,7 @@ mod navigation_and_filter { | |||
| 1046 | 1046 | fn initial_state_has_library_vfs() { | |
| 1047 | 1047 | let (state, _dir) = make_state(); | |
| 1048 | 1048 | assert!(!state.vfs_list.is_empty()); | |
| 1049 | - | assert_eq!(state.vfs_list[0].name, "Library"); | |
| 1049 | + | assert_eq!(state.vfs_list[0].name, "Vault"); | |
| 1050 | 1050 | assert_eq!(state.current_vfs_idx, 0); | |
| 1051 | 1051 | } | |
| 1052 | 1052 |
| @@ -253,6 +253,10 @@ pub struct EditUiState { | |||
| 253 | 253 | pub fade_duration_ms: f64, | |
| 254 | 254 | pub fade_curve: FadeCurve, | |
| 255 | 255 | pub result_mode: Option<EditResultMode>, | |
| 256 | + | pub silence_position_ms: f64, | |
| 257 | + | pub silence_duration_ms: f64, | |
| 258 | + | pub remove_start_ms: f64, | |
| 259 | + | pub remove_end_ms: f64, | |
| 256 | 260 | } | |
| 257 | 261 | ||
| 258 | 262 | impl Default for EditUiState { | |
| @@ -273,6 +277,10 @@ impl Default for EditUiState { | |||
| 273 | 277 | fade_duration_ms: 100.0, | |
| 274 | 278 | fade_curve: FadeCurve::Linear, | |
| 275 | 279 | result_mode: None, | |
| 280 | + | silence_position_ms: 0.0, | |
| 281 | + | silence_duration_ms: 100.0, | |
| 282 | + | remove_start_ms: 0.0, | |
| 283 | + | remove_end_ms: 100.0, | |
| 276 | 284 | } | |
| 277 | 285 | } | |
| 278 | 286 | } | |
| @@ -320,6 +328,8 @@ pub enum ImportMode { | |||
| 320 | 328 | available_vfs: Vec<audiofiles_core::vfs::Vfs>, | |
| 321 | 329 | selected_merge_vfs_idx: usize, | |
| 322 | 330 | new_vfs_name: String, | |
| 331 | + | /// Number of audio files found in the source folder (dry-run scan). | |
| 332 | + | audio_file_count: usize, | |
| 323 | 333 | }, | |
| 324 | 334 | Importing { | |
| 325 | 335 | total: usize, |
| @@ -171,7 +171,7 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 171 | 171 | ui.horizontal(|ui| { | |
| 172 | 172 | let resp = ui.add( | |
| 173 | 173 | egui::TextEdit::singleline(&mut state.tag_input) | |
| 174 | - | .hint_text("Add tag...") | |
| 174 | + | .hint_text("Add tag (use dots: genre.house)") | |
| 175 | 175 | .desired_width(ui.available_width() - 40.0), | |
| 176 | 176 | ); | |
| 177 | 177 | if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) | |
| @@ -192,6 +192,30 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 192 | 192 | } | |
| 193 | 193 | }); | |
| 194 | 194 | ||
| 195 | + | // Tag suggestions based on classification | |
| 196 | + | if let Some(ref analysis) = state.selected_analysis { | |
| 197 | + | if let Some(ref class) = analysis.classification { | |
| 198 | + | let class_str = class.to_string(); | |
| 199 | + | let suggestions = classification_tag_suggestions(&class_str, &state.selected_tags); | |
| 200 | + | if !suggestions.is_empty() { | |
| 201 | + | ui.add_space(2.0); | |
| 202 | + | ui.horizontal_wrapped(|ui| { | |
| 203 | + | ui.label(egui::RichText::new("Suggest:").small().color(theme::text_muted())); | |
| 204 | + | for sug in &suggestions { | |
| 205 | + | if ui.small_button( | |
| 206 | + | egui::RichText::new(format!("+{sug}")).small().color(theme::accent_blue()) | |
| 207 | + | ).on_hover_text(format!("Add tag: {sug}")).clicked() { | |
| 208 | + | if let Some(ref hash) = node.node.sample_hash { | |
| 209 | + | let _ = state.backend.add_tag(hash, sug); | |
| 210 | + | state.refresh_selected_tags(); | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | }); | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | } | |
| 218 | + | ||
| 195 | 219 | ui.add_space(12.0); | |
| 196 | 220 | ui.separator(); | |
| 197 | 221 | ui.add_space(4.0); | |
| @@ -211,4 +235,47 @@ pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 211 | 235 | } | |
| 212 | 236 | } | |
| 213 | 237 | }); | |
| 238 | + | ||
| 239 | + | // Discovery buttons | |
| 240 | + | if let Some(hash) = &node.node.sample_hash { | |
| 241 | + | ui.add_space(4.0); | |
| 242 | + | ui.horizontal(|ui| { | |
| 243 | + | let hash = hash.clone(); | |
| 244 | + | if ui.button("Find Similar").on_hover_text("Find similar samples (Shift+F)").clicked() { | |
| 245 | + | state.find_similar(&hash); | |
| 246 | + | } | |
| 247 | + | if ui.button("Find Duplicates").on_hover_text("Find near-duplicates (Shift+D)").clicked() { | |
| 248 | + | state.find_near_duplicates(&hash); | |
| 249 | + | } | |
| 250 | + | }); | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | /// Suggest tags based on the sample's classification. Excludes tags already applied. | |
| 255 | + | fn classification_tag_suggestions(classification: &str, existing_tags: &[String]) -> Vec<&'static str> { | |
| 256 | + | let candidates: &[&str] = match classification { | |
| 257 | + | "kick" => &["drums.kick", "percussion", "one-shot"], | |
| 258 | + | "snare" => &["drums.snare", "percussion", "one-shot"], | |
| 259 | + | "hihat" => &["drums.hihat", "percussion", "one-shot"], | |
| 260 | + | "cymbal" => &["drums.cymbal", "percussion", "one-shot"], | |
| 261 | + | "percussion" => &["percussion", "one-shot"], | |
| 262 | + | "bass" => &["bass", "synth.bass"], | |
| 263 | + | "vocal" => &["vocal"], | |
| 264 | + | "synth" => &["synth", "melodic"], | |
| 265 | + | "pad" => &["synth.pad", "melodic", "texture"], | |
| 266 | + | "fx" => &["fx", "texture"], | |
| 267 | + | "noise" => &["noise", "texture"], | |
| 268 | + | "music" => &["loop", "melodic"], | |
| 269 | + | "ambience" => &["ambience", "texture", "field-recording"], | |
| 270 | + | "impact" => &["fx.impact", "one-shot"], | |
| 271 | + | "foley" => &["foley", "field-recording"], | |
| 272 | + | "texture" => &["texture"], | |
| 273 | + | _ => &[], | |
| 274 | + | }; | |
| 275 | + | ||
| 276 | + | candidates | |
| 277 | + | .iter() | |
| 278 | + | .filter(|&&tag| !existing_tags.iter().any(|t| t == tag || t.starts_with(&format!("{tag}.")))) | |
| 279 | + | .copied() | |
| 280 | + | .collect() | |
| 214 | 281 | } |
| @@ -52,7 +52,14 @@ pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 52 | 52 | draw_transform_section(ui, state); | |
| 53 | 53 | ||
| 54 | 54 | ui.separator(); | |
| 55 | + | draw_silence_section(ui, state); | |
| 56 | + | ||
| 57 | + | ui.separator(); | |
| 55 | 58 | draw_result_section(ui, state); | |
| 59 | + | ||
| 60 | + | // Batch edit section (shown when multiple samples selected) | |
| 61 | + | ui.separator(); | |
| 62 | + | draw_batch_section(ui, state); | |
| 56 | 63 | }); | |
| 57 | 64 | state.edit.show_window = open; | |
| 58 | 65 | } | |
| @@ -263,6 +270,96 @@ fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 263 | 270 | }); | |
| 264 | 271 | } | |
| 265 | 272 | ||
| 273 | + | /// Silence section: insert or remove silence. | |
| 274 | + | fn draw_silence_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 275 | + | let disabled = state.edit.in_progress || state.edit.hash.is_none(); | |
| 276 | + | ||
| 277 | + | ui.label(egui::RichText::new("Silence").strong()); | |
| 278 | + | ||
| 279 | + | // Insert silence | |
| 280 | + | ui.horizontal(|ui| { | |
| 281 | + | ui.label("Insert at:"); | |
| 282 | + | ui.add_enabled( | |
| 283 | + | !disabled, | |
| 284 | + | egui::DragValue::new(&mut state.edit.silence_position_ms) | |
| 285 | + | .speed(10.0) | |
| 286 | + | .range(0.0..=f64::MAX) | |
| 287 | + | .suffix(" ms"), | |
| 288 | + | ); | |
| 289 | + | ui.label("Duration:"); | |
| 290 | + | ui.add_enabled( | |
| 291 | + | !disabled, | |
| 292 | + | egui::DragValue::new(&mut state.edit.silence_duration_ms) | |
| 293 | + | .speed(10.0) | |
| 294 | + | .range(1.0..=60000.0) | |
| 295 | + | .suffix(" ms"), | |
| 296 | + | ); | |
| 297 | + | if ui.add_enabled(!disabled, egui::Button::new("Insert")).clicked() { | |
| 298 | + | state.apply_edit_insert_silence(); | |
| 299 | + | } | |
| 300 | + | }); | |
| 301 | + | ||
| 302 | + | // Remove range | |
| 303 | + | ui.horizontal(|ui| { | |
| 304 | + | ui.label("Remove from:"); | |
| 305 | + | ui.add_enabled( | |
| 306 | + | !disabled, | |
| 307 | + | egui::DragValue::new(&mut state.edit.remove_start_ms) | |
| 308 | + | .speed(10.0) | |
| 309 | + | .range(0.0..=f64::MAX) | |
| 310 | + | .suffix(" ms"), | |
| 311 | + | ); | |
| 312 | + | ui.label("to:"); | |
| 313 | + | ui.add_enabled( | |
| 314 | + | !disabled, | |
| 315 | + | egui::DragValue::new(&mut state.edit.remove_end_ms) | |
| 316 | + | .speed(10.0) | |
| 317 | + | .range(0.0..=f64::MAX) | |
| 318 | + | .suffix(" ms"), | |
| 319 | + | ); | |
| 320 | + | if ui.add_enabled(!disabled, egui::Button::new("Remove")).clicked() { | |
| 321 | + | state.apply_edit_remove_range(); | |
| 322 | + | } | |
| 323 | + | }); | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | /// Batch edit section: apply operations to all selected samples. | |
| 327 | + | fn draw_batch_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 328 | + | let selected_count = state.selected_sample_hashes().len(); | |
| 329 | + | if selected_count < 2 { | |
| 330 | + | return; | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | ui.label( | |
| 334 | + | egui::RichText::new(format!("Batch Edit ({} samples)", selected_count)) | |
| 335 | + | .strong() | |
| 336 | + | .color(theme::accent_blue()), | |
| 337 | + | ); | |
| 338 | + | ui.label( | |
| 339 | + | egui::RichText::new("Apply to all selected samples at once") | |
| 340 | + | .small() | |
| 341 | + | .color(theme::text_muted()), | |
| 342 | + | ); | |
| 343 | + | ui.add_space(4.0); | |
| 344 | + | ||
| 345 | + | ui.horizontal(|ui| { | |
| 346 | + | if ui.button("Normalize Peak").on_hover_text(format!("Normalize {} samples to {:.1} dBFS", selected_count, state.edit.norm_target)).clicked() { | |
| 347 | + | state.batch_normalize_peak(state.edit.norm_target); | |
| 348 | + | } | |
| 349 | + | if ui.button("Normalize LUFS").on_hover_text(format!("Normalize {} samples to {:.1} LUFS", selected_count, state.edit.norm_target)).clicked() { | |
| 350 | + | state.batch_normalize_lufs(state.edit.norm_target); | |
| 351 | + | } | |
| 352 | + | }); | |
| 353 | + | ui.horizontal(|ui| { | |
| 354 | + | if ui.button("Gain").on_hover_text(format!("Apply {:.1} dB to {} samples", state.edit.gain_db, selected_count)).clicked() { | |
| 355 | + | state.batch_gain(state.edit.gain_db); | |
| 356 | + | } | |
| 357 | + | if ui.button("Reverse").on_hover_text(format!("Reverse {} samples", selected_count)).clicked() { | |
| 358 | + | state.batch_reverse(); | |
| 359 | + | } | |
| 360 | + | }); | |
| 361 | + | } | |
| 362 | + | ||
| 266 | 363 | /// Result mode section: replace original vs create sibling. | |
| 267 | 364 | fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) { | |
| 268 | 365 | ui.label(egui::RichText::new("Result").strong()); |
| @@ -1,5 +1,7 @@ | |||
| 1 | 1 | //! Export workflow screens: configure export settings, progress bar, and completion summary. | |
| 2 | 2 | ||
| 3 | + | use std::path::Path; | |
| 4 | + | ||
| 3 | 5 | use egui; | |
| 4 | 6 | ||
| 5 | 7 | use crate::state::{BrowserState, ImportMode}; | |
| @@ -7,6 +9,47 @@ use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat}; | |||
| 7 | 9 | ||
| 8 | 10 | use super::theme; | |
| 9 | 11 | ||
| 12 | + | /// Query available disk space on the filesystem containing the given path. | |
| 13 | + | #[cfg(unix)] | |
| 14 | + | fn available_disk_space(path: &Path) -> Option<u64> { | |
| 15 | + | use std::ffi::CString; | |
| 16 | + | use std::os::unix::ffi::OsStrExt; | |
| 17 | + | ||
| 18 | + | let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; | |
| 19 | + | unsafe { | |
| 20 | + | let mut stat: libc::statvfs = std::mem::zeroed(); | |
| 21 | + | if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 { | |
| 22 | + | Some(stat.f_bavail as u64 * stat.f_frsize as u64) | |
| 23 | + | } else { | |
| 24 | + | None | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | #[cfg(windows)] | |
| 30 | + | fn available_disk_space(path: &Path) -> Option<u64> { | |
| 31 | + | use std::os::windows::ffi::OsStrExt; | |
| 32 | + | let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); | |
| 33 | + | let mut free_bytes: u64 = 0; | |
| 34 | + | unsafe { | |
| 35 | + | if windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW( | |
| 36 | + | windows::core::PCWSTR(wide.as_ptr()), | |
| 37 | + | Some(&mut free_bytes), | |
| 38 | + | None, | |
| 39 | + | None, | |
| 40 | + | ).is_ok() { | |
| 41 | + | Some(free_bytes) | |
| 42 | + | } else { | |
| 43 | + | None | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | #[cfg(not(any(unix, windows)))] | |
| 49 | + | fn available_disk_space(_path: &Path) -> Option<u64> { | |
| 50 | + | None | |
| 51 | + | } | |
| 52 | + | ||
| 10 | 53 | /// Draw the export configuration screen. | |
| 11 | 54 | pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |
| 12 | 55 | let (item_count, profile_count) = match &state.import_mode { | |
| @@ -20,6 +63,84 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 20 | 63 | ||
| 21 | 64 | egui::TopBottomPanel::bottom("export_footer").show(ctx, |ui| { | |
| 22 | 65 | ui.add_space(4.0); | |
| 66 | + | ||
| 67 | + | // Warnings | |
| 68 | + | if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_mode { | |
| 69 | + | // AIFF size limit warning: ~24 min stereo 24-bit exceeds u32 chunk limit | |
| 70 | + | if config.format == ExportFormat::Aiff { | |
| 71 | + | let max_duration = items.iter().filter_map(|i| i.duration).fold(0.0f64, f64::max); | |
| 72 | + | // u32::MAX bytes / (channels * bytes_per_sample * sample_rate) | |
| 73 | + | // Worst case: stereo 24-bit 48kHz = 2 * 3 * 48000 = 288000 bytes/sec | |
| 74 | + | // 4_294_967_295 / 288000 ~ 14913 seconds ~ 248 min | |
| 75 | + | // More restrictive: stereo 24-bit 96kHz = 576000 B/s → ~7456s ~ 124 min | |
| 76 | + | // Conservative threshold: warn at 20 min for any config | |
| 77 | + | if max_duration > 1200.0 { | |
| 78 | + | ui.label( | |
| 79 | + | egui::RichText::new( | |
| 80 | + | "\u{26A0} Warning: AIFF format has a 4 GB chunk size limit. \ | |
| 81 | + | Long samples may fail to export." | |
| 82 | + | ) | |
| 83 | + | .small() | |
| 84 | + | .color(theme::accent_red()), | |
| 85 | + | ); | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Device file size limit warning | |
| 90 | + | if let Some(ref profile_name) = config.device_profile { | |
| 91 | + | if let Some(profile) = available_profiles.iter().find(|p| &p.name == profile_name) { | |
| 92 | + | if let Some(max_bytes) = profile.max_file_size_bytes { | |
| 93 | + | // Estimate: duration * sample_rate * channels * bytes_per_sample | |
| 94 | + | // Use worst case: stereo 24-bit at 48kHz = 288000 bytes/sec | |
| 95 | + | let bytes_per_sec: f64 = 288_000.0; | |
| 96 | + | let over_limit: Vec<&str> = items.iter() | |
| 97 | + | .filter(|item| { | |
| 98 | + | item.duration | |
| 99 | + | .map(|d| (d * bytes_per_sec) as u64 > max_bytes) | |
| 100 | + | .unwrap_or(false) | |
| 101 | + | }) | |
| 102 | + | .map(|item| item.name.as_str()) | |
| 103 | + | .collect(); | |
| 104 | + | if !over_limit.is_empty() { | |
| 105 | + | let msg = if over_limit.len() == 1 { | |
| 106 | + | format!( | |
| 107 | + | "\u{26A0} \"{}\" may exceed device file size limit ({:.0} MB)", | |
| 108 | + | over_limit[0], | |
| 109 | + | max_bytes as f64 / 1_048_576.0, | |
| 110 | + | ) | |
| 111 | + | } else { | |
| 112 | + | format!( | |
| 113 | + | "\u{26A0} {} samples may exceed device file size limit ({:.0} MB)", | |
| 114 | + | over_limit.len(), | |
| 115 | + | max_bytes as f64 / 1_048_576.0, | |
| 116 | + | ) | |
| 117 | + | }; | |
| 118 | + | ui.label( | |
| 119 | + | egui::RichText::new(msg).small().color(theme::accent_red()), | |
| 120 | + | ); | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Disk space check | |
| 127 | + | if let Some(available) = available_disk_space(&config.destination) { | |
| 128 | + | // Rough estimate: item count * avg sample size. Use 10 MB per item as heuristic. | |
| 129 | + | let estimated_bytes = items.len() as u64 * 10 * 1024 * 1024; | |
| 130 | + | if available < estimated_bytes { | |
| 131 | + | ui.label( | |
| 132 | + | egui::RichText::new(format!( | |
| 133 | + | "\u{26A0} Low disk space: {:.1} GB available, estimated {:.1} GB needed", | |
| 134 | + | available as f64 / 1_073_741_824.0, | |
| 135 | + | estimated_bytes as f64 / 1_073_741_824.0, | |
| 136 | + | )) | |
| 137 | + | .small() | |
| 138 | + | .color(theme::accent_red()), | |
| 139 | + | ); | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 23 | 144 | ui.horizontal(|ui| { | |
| 24 | 145 | if ui.button("Export").clicked() { | |
| 25 | 146 | if let ImportMode::ConfigureExport { ref items, ref config, .. } = | |
| @@ -41,8 +162,17 @@ pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) { | |||
| 41 | 162 | egui::CentralPanel::default().show(ctx, |ui| { | |
| 42 | 163 | egui::ScrollArea::vertical().show(ui, |ui| { | |
| 43 | 164 | ui.heading("Export Samples"); | |
| 44 | - | ui.add_space(8.0); | |
| 45 | - | ui.label(format!("{item_count} samples to export")); | |
| 165 | + | ui.add_space(4.0); | |
| 166 | + | ui.horizontal(|ui| { | |
| 167 | + | ui.label(format!("{item_count} samples to export")); | |
| 168 | + | if profile_count > 0 { | |
| 169 | + | ui.label( | |
| 170 | + | egui::RichText::new(format!("\u{00B7} {} device profiles available", profile_count)) | |
| 171 | + | .small() | |
| 172 | + | .color(theme::text_muted()), | |
| 173 | + | ); | |
| 174 | + | } | |
| 175 | + | }); | |
| 46 | 176 | ui.add_space(12.0); | |
| 47 | 177 | ||
| 48 | 178 | // --- Device Profile --- |
| @@ -36,23 +36,117 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 36 | 36 | // Empty state: no contents, at root level, no active search | |
| 37 | 37 | if state.contents.is_empty() && state.current_dir.is_none() && state.search_query.is_empty() && !state.search_filter.is_active() { | |
| 38 | 38 | ui.vertical_centered(|ui| { | |
| 39 | + | ui.add_space(ui.available_height() * 0.15); | |
| 40 | + | ||
| 41 | + | if state.show_first_launch_hint { | |
| 42 | + | // First-run guided onboarding | |
| 43 | + | ui.label( | |
| 44 | + | egui::RichText::new("Welcome to audiofiles") | |
| 45 | + | .size(22.0) | |
| 46 | + | .color(theme::text_primary()), | |
| 47 | + | ); | |
| 48 | + | ui.add_space(16.0); | |
| 49 | + | ui.label( | |
| 50 | + | egui::RichText::new("Get started in seconds:") | |
| 51 | + | .color(theme::text_secondary()), | |
| 52 | + | ); | |
| 53 | + | ui.add_space(12.0); | |
| 54 | + | ||
| 55 | + | // Step 1 | |
| 56 | + | ui.horizontal(|ui| { | |
| 57 | + | ui.label(egui::RichText::new("1.").strong().color(theme::accent_blue())); | |
| 58 | + | ui.label("Drop a folder of samples onto this window, or click "); | |
| 59 | + | if ui.link("Import").clicked() { | |
| 60 | + | // Trigger quick import file dialog | |
| 61 | + | if let Some(path) = rfd::FileDialog::new() | |
| 62 | + | .set_title("Quick Import Folder") | |
| 63 | + | .pick_folder() | |
| 64 | + | { | |
| 65 | + | state.quick_import_folder(path); | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | }); | |
| 69 | + | ui.add_space(6.0); | |
| 70 | + | ||
| 71 | + | // Step 2 | |
| 72 | + | ui.horizontal(|ui| { | |
| 73 | + | ui.label(egui::RichText::new("2.").strong().color(theme::accent_blue())); | |
| 74 | + | ui.label("audiofiles will analyze BPM, key, and type automatically"); | |
| 75 | + | }); | |
| 76 | + | ui.add_space(6.0); | |
| 77 | + | ||
| 78 | + | // Step 3 | |
| 79 | + | ui.horizontal(|ui| { | |
| 80 | + | ui.label(egui::RichText::new("3.").strong().color(theme::accent_blue())); | |
| 81 | + | ui.label("Browse, filter, preview, and export to your hardware"); | |
| 82 | + | }); | |
| 83 | + | ||
| 84 | + | ui.add_space(20.0); | |
| 85 | + | ui.label( | |
| 86 | + | egui::RichText::new("Your files stay where they are \u{2014} audiofiles only indexes them.") | |
| 87 | + | .small() | |
| 88 | + | .color(theme::text_muted()), | |
| 89 | + | ); | |
| 90 | + | ui.add_space(8.0); | |
| 91 | + | ui.label( | |
| 92 | + | egui::RichText::new("F1 for keyboard shortcuts \u{00B7} Right-click for options") | |
| 93 | + | .small() | |
| 94 | + | .color(theme::text_muted()), | |
| 95 | + | ); | |
| 96 | + | } else { | |
| 97 | + | // Returning user, empty vault | |
| 98 | + | ui.label( | |
| 99 | + | egui::RichText::new("\u{1F3B5}") | |
| 100 | + | .size(48.0) | |
| 101 | + | .color(theme::text_muted()), | |
| 102 | + | ); | |
| 103 | + | ui.add_space(12.0); | |
| 104 | + | ui.label( | |
| 105 | + | egui::RichText::new("No samples yet") | |
| 106 | + | .size(20.0) | |
| 107 | + | .color(theme::text_secondary()), | |
| 108 | + | ); | |
| 109 | + | ui.add_space(8.0); | |
| 110 | + | ui.label( | |
| 111 | + | egui::RichText::new("Drop audio files here or click Import to get started.") | |
| 112 | + | .color(theme::text_muted()), | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | }); | |
| 116 | + | return; | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Empty state: filters active but no results in this folder | |
| 120 | + | if state.contents.is_empty() && (state.search_filter.is_active() || !state.search_query.is_empty()) { | |
| 121 | + | ui.vertical_centered(|ui| { | |
| 39 | 122 | ui.add_space(ui.available_height() * 0.25); | |
| 40 | 123 | ui.label( | |
| 41 | - | egui::RichText::new("\u{1F3B5}") | |
| 42 | - | .size(48.0) | |
| 124 | + | egui::RichText::new("\u{1F50D}") | |
| 125 | + | .size(36.0) | |
| 43 | 126 | .color(theme::text_muted()), | |
| 44 | 127 | ); | |
| 45 | 128 | ui.add_space(12.0); | |
| 46 | 129 | ui.label( | |
| 47 | - | egui::RichText::new("No samples yet") | |
| 48 | - | .size(20.0) | |
| 130 | + | egui::RichText::new("No matches in this folder") | |
| 131 | + | .size(18.0) | |
| 49 | 132 | .color(theme::text_secondary()), | |
| 50 | 133 | ); | |
| 51 | 134 | ui.add_space(8.0); | |
| 52 | - | ui.label( | |
| 53 | - | egui::RichText::new("Drop audio files here or click Import to get started.") | |
| 54 | - | .color(theme::text_muted()), | |
| 55 | - | ); | |
| 135 | + | let filter_count = state.search_filter.active_count(); | |
| 136 | + | let hint = if filter_count > 0 && !state.search_query.is_empty() { | |
| 137 | + | format!("{} filter{} + search active", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 138 | + | } else if filter_count > 0 { | |
| 139 | + | format!("{} filter{} active — try broadening your criteria or searching All vaults", filter_count, if filter_count == 1 { "" } else { "s" }) | |
| 140 | + | } else { | |
| 141 | + | "No samples match your search in this folder.".to_string() | |
| 142 | + | }; | |
| 143 | + | ui.label(egui::RichText::new(&hint).color(theme::text_muted())); | |
| 144 | + | ui.add_space(12.0); | |
| 145 | + | if ui.button("Clear Filters").clicked() { | |
| 146 | + | state.search_filter.clear(); | |
| 147 | + | state.search_query.clear(); | |
| 148 | + | state.apply_search(); | |
| 149 | + | } | |
| 56 | 150 | }); | |
| 57 | 151 | return; | |
| 58 | 152 | } | |
| @@ -306,6 +400,7 @@ fn draw_name_column( | |||
| 306 | 400 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 307 | 401 | let resp = if !node.cloud_only && node.node.node_type == NodeType::Sample { | |
| 308 | 402 | resp.interact(egui::Sense::drag()) | |
| 403 | + | .on_hover_text_at_pointer("Drag to Finder or DAW") | |
| 309 | 404 | } else { | |
| 310 | 405 | resp | |
| 311 | 406 | }; |
| @@ -43,14 +43,14 @@ pub fn draw_context_menu( | |||
| 43 | 43 | } | |
| 44 | 44 | ui.close_menu(); | |
| 45 | 45 | } | |
| 46 | - | if ui.button("Find Similar").clicked() { | |
| 46 | + | if ui.button("Find Similar (Shift+F)").clicked() { | |
| 47 | 47 | if let Some(hash) = &node.node.sample_hash { | |
| 48 | 48 | let hash = hash.clone(); | |
| 49 | 49 | state.find_similar(&hash); | |
| 50 | 50 | } | |
| 51 | 51 | ui.close_menu(); | |
| 52 | 52 | } | |
| 53 | - | if ui.button("Find Duplicates").clicked() { | |
| 53 | + | if ui.button("Find Duplicates (Shift+D)").clicked() { | |
| 54 | 54 | if let Some(hash) = &node.node.sample_hash { | |
| 55 | 55 | let hash = hash.clone(); | |
| 56 | 56 | state.find_near_duplicates(&hash); | |
| @@ -88,7 +88,7 @@ pub fn draw_context_menu( | |||
| 88 | 88 | if !node.cloud_only { | |
| 89 | 89 | if let Some(hash) = &node.node.sample_hash { | |
| 90 | 90 | let hash_clone = hash.clone(); | |
| 91 | - | if ui.button("Edit...").clicked() { | |
| 91 | + | if ui.button("Edit... (E)").clicked() { | |
| 92 | 92 | state.open_edit_window(&hash_clone); | |
| 93 | 93 | ui.close_menu(); | |
| 94 | 94 | } | |
| @@ -153,15 +153,15 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 153 | 153 | ui.label(egui::RichText::new(format!("{count} items selected")).strong()); | |
| 154 | 154 | ui.separator(); | |
| 155 | 155 | ||
| 156 | - | if ui.button("Tag...").clicked() { | |
| 156 | + | if ui.button("Tag... (Cmd+T)").clicked() { | |
| 157 | 157 | state.open_bulk_tag_modal(); | |
| 158 | 158 | ui.close_menu(); | |
| 159 | 159 | } | |
| 160 | - | if ui.button("Move to...").clicked() { | |
| 160 | + | if ui.button("Move to... (Cmd+M)").clicked() { | |
| 161 | 161 | state.open_bulk_move_modal(); | |
| 162 | 162 | ui.close_menu(); | |
| 163 | 163 | } | |
| 164 | - | if ui.button("Rename...").clicked() { | |
| 164 | + | if ui.button("Rename... (F2)").clicked() { | |
| 165 | 165 | state.open_bulk_rename_modal(); | |
| 166 | 166 | ui.close_menu(); | |
| 167 | 167 | } | |
| @@ -208,6 +208,49 @@ pub fn draw_multi_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 208 | 208 | ||
| 209 | 209 | ui.separator(); | |
| 210 | 210 | ||
| 211 | + | if ui.button("Re-analyze...").on_hover_text("Run analysis again on selected samples").clicked() { | |
| 212 | + | let hashes: Vec<(String, String)> = state.selected_nodes() | |
| 213 | + | .iter() | |
| 214 | + | .filter_map(|n| { | |
| 215 | + | let hash = n.node.sample_hash.as_ref()?; | |
| 216 | + | let ext = state.backend.sample_extension(hash).ok()?; | |
| 217 | + | Some((hash.to_string(), ext)) | |
| 218 | + | }) | |
| 219 | + | .collect(); | |
| 220 | + | state.start_analysis_flow(hashes); | |
| 221 | + | ui.close_menu(); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | // Copy tags from focused sample to all selected | |
| 225 | + | if let Some(focused) = state.selected_node() { | |
| 226 | + | if let Some(ref src_hash) = focused.node.sample_hash { | |
| 227 | + | let src_hash = src_hash.clone(); | |
| 228 | + | let src_name = focused.node.name.clone(); | |
| 229 | + | if ui.button(format!("Copy Tags from \"{}\"", truncate_name(&src_name, 20))) | |
| 230 | + | .on_hover_text("Apply this sample's tags to all other selected samples") | |
| 231 | + | .clicked() | |
| 232 | + | { | |
| 233 | + | let src_hash_str = src_hash.to_string(); | |
| 234 | + | if let Ok(src_tags) = state.backend.get_sample_tags(&src_hash) { | |
| 235 | + | let target_hashes = state.selected_sample_hashes(); | |
| 236 | + | let mut applied = 0; | |
| 237 | + | for hash in &target_hashes { | |
| 238 | + | if *hash == src_hash_str { continue; } | |
| 239 | + | for tag in &src_tags { | |
| 240 | + | let _ = state.backend.add_tag(hash, tag); | |
| 241 | + | } | |
| 242 | + | applied += 1; | |
| 243 | + | } | |
| 244 | + | state.status = format!("Copied {} tags to {} samples", src_tags.len(), applied); | |
| 245 | + | state.refresh_selected_tags(); | |
| 246 | + | } | |
| 247 | + | ui.close_menu(); | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | ui.separator(); | |
| 253 | + | ||
| 211 | 254 | if ui.button("Copy Paths").clicked() { | |
| 212 | 255 | let nodes = state.selected_nodes(); | |
| 213 | 256 | let paths: Vec<String> = nodes | |
| @@ -269,6 +312,15 @@ pub fn draw_background_context_menu(ui: &mut egui::Ui, state: &mut BrowserState) | |||
| 269 | 312 | } | |
| 270 | 313 | } | |
| 271 | 314 | ||
| 315 | + | /// Truncate a name for display in menus (avoids excessively wide menu items). | |
| 316 | + | fn truncate_name(name: &str, max_len: usize) -> String { | |
| 317 | + | if name.len() <= max_len { | |
| 318 | + | name.to_string() | |
| 319 | + | } else { | |
| 320 | + | format!("{}...", &name[..max_len.saturating_sub(3)]) | |
| 321 | + | } | |
| 322 | + | } | |
| 323 | + | ||
| 272 | 324 | #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] | |
| 273 | 325 | pub fn start_os_drag(state: &mut BrowserState) { | |
| 274 | 326 | let nodes = state.selected_nodes(); |
| @@ -55,6 +55,26 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 55 | 55 | }); | |
| 56 | 56 | }); | |
| 57 | 57 | ||
| 58 | + | // Loudness (peak dB) range | |
| 59 | + | let loud_active = state.search_filter.peak_db_min.is_some() || state.search_filter.peak_db_max.is_some(); | |
| 60 | + | let loud_header = if loud_active { "Loudness (dB) *" } else { "Loudness (dB)" }; | |
| 61 | + | egui::CollapsingHeader::new(loud_header) | |
| 62 | + | .default_open(loud_active) | |
| 63 | + | .show(ui, |ui| { | |
| 64 | + | ui.horizontal(|ui| { | |
| 65 | + | let mut min = state.search_filter.peak_db_min.unwrap_or(-96.0); | |
| 66 | + | let mut max = state.search_filter.peak_db_max.unwrap_or(0.0); | |
| 67 | + | if ui.add(egui::DragValue::new(&mut min).speed(0.5).range(-96.0..=0.0).prefix("Min: ").suffix(" dB")).changed() { | |
| 68 | + | state.search_filter.peak_db_min = if min > -96.0 { Some(min) } else { None }; | |
| 69 | + | changed = true; | |
| 70 | + | } | |
| 71 | + | if ui.add(egui::DragValue::new(&mut max).speed(0.5).range(-96.0..=0.0).prefix("Max: ").suffix(" dB")).changed() { | |
| 72 | + | state.search_filter.peak_db_max = if max < 0.0 { Some(max) } else { None }; | |
| 73 | + | changed = true; | |
| 74 | + | } | |
| 75 | + | }); | |
| 76 | + | }); | |
| 77 | + | ||
| 58 | 78 | // Classification checkboxes | |
| 59 | 79 | let class_active = !state.search_filter.classifications.is_empty(); | |
| 60 | 80 | let class_header = if class_active { "Classification *" } else { "Classification" }; | |
| @@ -151,25 +171,31 @@ pub fn draw_filter_panel(ui: &mut egui::Ui, state: &mut BrowserState) { | |||
| 151 | 171 | changed = true; | |
| 152 | 172 | } | |
| 153 | 173 | ||
| 154 | - | // Save as Smart Folder (only when filters are active) | |
| 174 | + | // Save as Collection (only when filters are active) | |
| 155 | 175 | if state.search_filter.is_active() { | |
| 156 | 176 | ui.add_space(8.0); | |
| 157 | 177 | ui.separator(); | |
| 158 | - | ui.label(egui::RichText::new("Save as Smart Folder").strong().color(theme::text_secondary())); | |
| 159 | - | ui.label(egui::RichText::new("Save current filters as a reusable preset") | |
| 178 | + | ui.label(egui::RichText::new("Save as Collection").strong().color(theme::text_secondary())); | |
| 179 | + | ui.label(egui::RichText::new("Save current filters as a dynamic collection") | |
| 160 | 180 | .small() | |
| 161 | 181 | .color(theme::text_muted())); | |
| 162 | 182 | ui.add_space(4.0); | |
| 163 | 183 | ui.horizontal(|ui| { | |
| 184 | + | let auto_name = state.search_filter.describe(); | |
| 164 | 185 | ui.add( | |
| 165 | - | egui::TextEdit::singleline(&mut state.smart_folder_name_input) | |
| 166 | - | .hint_text("e.g. Kicks Under 120 BPM") | |
| 186 | + | egui::TextEdit::singleline(&mut state.collection_filter_name_input) | |
| 187 | + | .hint_text(&auto_name) | |
| 167 | 188 | .desired_width(ui.available_width() - 50.0), | |
| 168 | 189 | ); | |
| 169 | - | let name = state.smart_folder_name_input.trim().to_string(); | |
| 170 | - | if ui.add_enabled(!name.is_empty(), egui::Button::new("Save")).clicked() { | |
| 171 | - | state.save_smart_folder(&name); | |
| 172 | - | state.smart_folder_name_input.clear(); | |
| 190 | + | // Use auto-generated name if user hasn't typed anything | |
| 191 | + | let name = if state.collection_filter_name_input.trim().is_empty() { | |
| 192 | + | auto_name | |
| 193 | + | } else { | |
| 194 | + | state.collection_filter_name_input.trim().to_string() | |
| 195 | + | }; | |
| 196 | + | if ui.button("Save").clicked() { | |
| 197 | + | state.save_dynamic_collection(&name); | |
| 198 | + | state.collection_filter_name_input.clear(); | |
| 173 | 199 | } | |
| 174 | 200 | }); | |
| 175 | 201 | } |