Skip to main content

max / audiofiles

Code fuzz fixes, silence edit, UI improvements, sync hardening - New silence detection/editing module - Browser state, navigation, and import workflow fixes - UI improvements across detail, filter, toolbar, settings panels - Analysis pipeline refinements (basic, classify, loudness, spectral) - Export dither and profile improvements - Sync service state/upload/download/resolve hardening - Search and similarity refinements - Collections and rename improvements - Smart folders removed (superseded by saved queries) - Todo audit updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-05-04 03:00 UTC
Commit: 469a78c1a11091628176e6e008994af0323e0935
Parent: 345aa37
58 files changed, +1829 insertions, -640 deletions
M Cargo.lock +1
@@ -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",
M Cargo.toml +1
@@ -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 }
M docs/todo.md +159 -5