Skip to main content

max / audiofiles

Version bump to 0.2.1 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-12 02:03 UTC
Commit: fe650207144850d02561b43f399dfa7ebe5200c1
Parent: 4fd73fc
52 files changed, +3080 insertions, -2538 deletions
M Cargo.lock +11 -9
@@ -418,7 +418,7 @@ dependencies = [
418 418
419 419 [[package]]
420 420 name = "audiofiles-app"
421 - version = "0.1.0"
421 + version = "0.2.0"
422 422 dependencies = [
423 423 "audiofiles-browser",
424 424 "audiofiles-core",
@@ -436,7 +436,7 @@ dependencies = [
436 436
437 437 [[package]]
438 438 name = "audiofiles-browser"
439 - version = "0.1.0"
439 + version = "0.2.0"
440 440 dependencies = [
441 441 "audiofiles-core",
442 442 "audiofiles-rhai",
@@ -453,11 +453,12 @@ dependencies = [
453 453 "tempfile",
454 454 "thiserror 2.0.18",
455 455 "toml 0.8.23",
456 + "tracing",
456 457 ]
457 458
458 459 [[package]]
459 460 name = "audiofiles-core"
460 - version = "0.1.0"
461 + version = "0.2.0"
461 462 dependencies = [
462 463 "bs1770",
463 464 "hound",
@@ -475,7 +476,7 @@ dependencies = [
475 476
476 477 [[package]]
477 478 name = "audiofiles-ipc"
478 - version = "0.1.0"
479 + version = "0.2.0"
479 480 dependencies = [
480 481 "audiofiles-core",
481 482 "serde",
@@ -485,7 +486,7 @@ dependencies = [
485 486
486 487 [[package]]
487 488 name = "audiofiles-plugin"
488 - version = "0.1.0"
489 + version = "0.2.0"
489 490 dependencies = [
490 491 "audiofiles-browser",
491 492 "audiofiles-core",
@@ -497,7 +498,7 @@ dependencies = [
497 498
498 499 [[package]]
499 500 name = "audiofiles-rhai"
500 - version = "0.1.0"
501 + version = "0.2.0"
501 502 dependencies = [
502 503 "audiofiles-core",
503 504 "dirs",
@@ -510,7 +511,7 @@ dependencies = [
510 511
511 512 [[package]]
512 513 name = "audiofiles-sync"
513 - version = "0.1.0"
514 + version = "0.2.0"
514 515 dependencies = [
515 516 "audiofiles-core",
516 517 "base64",
@@ -522,6 +523,7 @@ dependencies = [
522 523 "serde_json",
523 524 "sha2",
524 525 "synckit-client",
526 + "tempfile",
525 527 "thiserror 2.0.18",
526 528 "tokio",
527 529 "tracing",
@@ -4971,7 +4973,7 @@ dependencies = [
4971 4973
4972 4974 [[package]]
4973 4975 name = "synckit-client"
4974 - version = "0.1.0"
4976 + version = "0.2.0"
4975 4977 dependencies = [
4976 4978 "argon2",
4977 4979 "base64",
@@ -6572,7 +6574,7 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
6572 6574
6573 6575 [[package]]
6574 6576 name = "xtask"
6575 - version = "0.1.0"
6577 + version = "0.2.0"
6576 6578 dependencies = [
6577 6579 "nih_plug_xtask",
6578 6580 ]
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-app"
3 - version = "0.1.0"
3 + version = "0.2.1"
4 4 edition.workspace = true
5 5
6 6 [dependencies]
@@ -88,7 +88,8 @@ fn create_sync_manager(
88 88 api_key,
89 89 };
90 90 let db_path = data_dir.join("audiofiles.db");
91 - let manager = SyncManager::new(config, db_path, runtime.clone());
91 + let content_dir = data_dir.join("samples");
92 + let manager = SyncManager::new(config, db_path, content_dir, runtime.clone());
92 93 manager.try_restore_session();
93 94 manager.start_scheduler();
94 95 Some(manager)
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "audiofiles-browser"
3 - version = "0.1.0"
3 + version = "0.2.1"
4 4 edition.workspace = true
5 5
6 6 [features]
@@ -22,6 +22,7 @@ rfd = { workspace = true }
22 22 serde = { workspace = true }
23 23 serde_json = { workspace = true }
24 24 rusqlite = { workspace = true }
25 + tracing = { workspace = true }
25 26
26 27 [dev-dependencies]
27 28 tempfile = "3.25.0"
@@ -83,6 +83,18 @@ pub fn draw_browser(
83 83 if state.show_help {
84 84 overlays::draw_help_overlay(ctx, state);
85 85 }
86 + if state.show_vfs_create {
87 + overlays::draw_vfs_create_modal(ctx, state);
88 + }
89 + if state.vfs_rename_target.is_some() {
90 + overlays::draw_vfs_rename_modal(ctx, state);
91 + }
92 + if state.show_dir_create {
93 + overlays::draw_dir_create_modal(ctx, state);
94 + }
95 + if state.dir_rename_target.is_some() {
96 + overlays::draw_dir_rename_modal(ctx, state);
97 + }
86 98
87 99 // Sync panel overlay
88 100 if state.show_sync_panel {
@@ -7,6 +7,8 @@ use std::path::PathBuf;
7 7 use std::sync::{mpsc, Mutex};
8 8 use std::thread;
9 9
10 + use tracing::error;
11 +
10 12 use audiofiles_core::export::{ExportConfig, ExportItem, ExportSummary};
11 13 use audiofiles_core::store::SampleStore;
12 14
@@ -102,7 +104,7 @@ fn worker_loop(
102 104 total: 0,
103 105 errors: vec![("init".to_string(), e.to_string())],
104 106 });
105 - eprintln!("Export worker failed to open store: {e}");
107 + error!("Export worker failed to open store: {e}");
106 108 return;
107 109 }
108 110 };
@@ -9,6 +9,8 @@ use std::path::{Path, PathBuf};
9 9 use std::sync::{mpsc, Mutex};
10 10 use std::thread;
11 11
12 + use tracing::error;
13 +
12 14 use audiofiles_core::db::Database;
13 15 use audiofiles_core::error::CoreError;
14 16 use audiofiles_core::store::SampleStore;
@@ -447,7 +449,7 @@ fn worker_loop(
447 449 duplicates: 0,
448 450 folders: Vec::new(),
449 451 });
450 - eprintln!("Import worker failed to open DB: {e}");
452 + error!("Import worker failed to open DB: {e}");
451 453 return;
452 454 }
453 455 };
@@ -462,7 +464,7 @@ fn worker_loop(
462 464 duplicates: 0,
463 465 folders: Vec::new(),
464 466 });
465 - eprintln!("Import worker failed to open store: {e}");
467 + error!("Import worker failed to open store: {e}");
466 468 return;
467 469 }
468 470 };
@@ -486,7 +488,7 @@ fn worker_loop(
486 488 duplicates: 0,
487 489 folders: Vec::new(),
488 490 });
489 - eprintln!("Failed to create VFS '{vfs_name}': {e}");
491 + error!("Failed to create VFS '{vfs_name}': {e}");
490 492 continue;
491 493 }
492 494 }
@@ -1,3 +1,5 @@
1 + use tracing::warn;
2 +
1 3 use super::*;
2 4
3 5 impl BrowserState {
@@ -178,7 +180,7 @@ impl BrowserState {
178 180 let node_ids: Vec<NodeId> = nodes.iter().map(|n| n.node.id).collect();
179 181 let names: Vec<String> = nodes.iter().map(|n| n.node.name.clone()).collect();
180 182 let directories = self.backend.list_all_directories(self.current_vfs_id()).unwrap_or_else(|e| {
181 - eprintln!("Failed to list directories: {e}");
183 + warn!("Failed to list directories: {e}");
182 184 Vec::new()
183 185 });
184 186 self.bulk_modal = Some(BulkModal::Move {
@@ -1,3 +1,5 @@
1 + use tracing::{error, warn};
2 +
1 3 use super::*;
2 4
3 5 impl BrowserState {
@@ -168,7 +170,7 @@ impl BrowserState {
168 170 .unwrap_or("folder")
169 171 .to_string();
170 172 let available_vfs = self.backend.list_vfs().unwrap_or_else(|e| {
171 - eprintln!("Failed to list VFS: {e}");
173 + warn!("Failed to list VFS: {e}");
172 174 Vec::new()
173 175 });
174 176 self.import_mode = ImportMode::ConfigureImport {
@@ -253,7 +255,7 @@ impl BrowserState {
253 255 folders,
254 256 } => {
255 257 self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| {
256 - eprintln!("Failed to refresh VFS list: {e}");
258 + error!("Failed to refresh VFS list: {e}");
257 259 Vec::new()
258 260 }));
259 261 self.refresh_contents();
@@ -8,6 +8,8 @@ use std::fs;
8 8 use std::path::{Path, PathBuf};
9 9 use std::sync::Arc;
10 10
11 + use tracing::{error, warn};
12 +
11 13 use audiofiles_core::analysis::config::AnalysisConfig;
12 14 use audiofiles_core::util::split_name_ext;
13 15 use audiofiles_core::analysis::suggest::TagSuggestion;
@@ -401,6 +403,14 @@ pub struct BrowserState {
401 403 pub show_help: bool,
402 404 pub pending_confirm: Option<ConfirmAction>,
403 405
406 + // VFS management modals
407 + pub vfs_create_input: String,
408 + pub vfs_rename_target: Option<(VfsId, String)>,
409 + pub dir_create_input: String,
410 + pub show_vfs_create: bool,
411 + pub show_dir_create: bool,
412 + pub dir_rename_target: Option<(NodeId, String)>,
413 +
404 414 // Bulk operations
405 415 pub undo_stack: Vec<UndoOp>,
406 416 pub bulk_modal: Option<BulkModal>,
@@ -469,11 +479,11 @@ impl BrowserState {
469 479 }
470 480
471 481 let contents = backend.list_children_enriched(vfs_list[0].id, None)
472 - .unwrap_or_else(|e| { eprintln!("Failed to load initial contents: {e}"); Vec::new() });
482 + .unwrap_or_else(|e| { error!("Failed to load initial contents: {e}"); Vec::new() });
473 483 let all_tags = backend.list_all_tags()
474 - .unwrap_or_else(|e| { eprintln!("Failed to load tags: {e}"); Vec::new() });
484 + .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() });
475 485 let smart_folders_list = backend.list_smart_folders(vfs_list[0].id)
476 - .unwrap_or_else(|e| { eprintln!("Failed to load smart folders: {e}"); Vec::new() });
486 + .unwrap_or_else(|e| { warn!("Failed to load smart folders: {e}"); Vec::new() });
477 487
478 488 // Load saved theme preference
479 489 let theme_id = backend.get_config("theme")
@@ -514,6 +524,12 @@ impl BrowserState {
514 524 instrument_root_note: 60,
515 525 show_help: false,
516 526 pending_confirm: None,
527 + vfs_create_input: String::new(),
528 + vfs_rename_target: None,
529 + dir_create_input: String::new(),
530 + show_vfs_create: false,
531 + show_dir_create: false,
532 + dir_rename_target: None,
517 533 undo_stack: Vec::new(),
518 534 bulk_modal: None,
519 535 column_config: ColumnConfig::default(),
@@ -729,7 +745,7 @@ impl BrowserState {
729 745 /// Reload the VFS list from the database and reset navigation to root.
730 746 pub fn refresh_vfs_list(&mut self) {
731 747 self.vfs_list = Arc::new(self.backend.list_vfs().unwrap_or_else(|e| {
732 - eprintln!("Failed to refresh VFS list: {e}");
748 + error!("Failed to refresh VFS list: {e}");
733 749 Vec::new()
734 750 }));
735 751 if self.current_vfs_idx >= self.vfs_list.len() {
@@ -745,7 +761,7 @@ impl BrowserState {
745 761 /// Refresh the cached list of all tags.
746 762 pub fn refresh_all_tags(&mut self) {
747 763 self.all_tags = Arc::new(self.backend.list_all_tags().unwrap_or_else(|e| {
748 - eprintln!("Failed to refresh tags: {e}");
764 + warn!("Failed to refresh tags: {e}");
749 765 Vec::new()
750 766 }));
751 767 }
@@ -757,7 +773,7 @@ impl BrowserState {
757 773 let vfs_id = self.vfs_list[self.current_vfs_idx].id;
758 774 self.smart_folders = self.backend.list_smart_folders(vfs_id)
759 775 .unwrap_or_else(|e| {
760 - eprintln!("Failed to load smart folders: {e}");
776 + warn!("Failed to load smart folders: {e}");
761 777 Vec::new()
762 778 });
763 779 }
@@ -1,3 +1,5 @@
1 + use tracing::{error, warn};
2 +
1 3 use super::*;
2 4
3 5 impl BrowserState {
@@ -23,7 +25,7 @@ impl BrowserState {
23 25 match self.backend.search_in_folder(&filter, vfs_id, self.current_dir) {
24 26 Ok(results) => self.contents = Arc::new(results),
25 27 Err(e) => {
26 - eprintln!("Search failed: {e}");
28 + error!("Search failed: {e}");
27 29 self.status = "Search error".to_string();
28 30 self.contents = Arc::new(Vec::new());
29 31 }
@@ -33,7 +35,7 @@ impl BrowserState {
33 35 match self.backend.search_global(&filter) {
34 36 Ok(results) => self.contents = Arc::new(results),
35 37 Err(e) => {
36 - eprintln!("Global search failed: {e}");
38 + error!("Global search failed: {e}");
37 39 self.status = "Search error".to_string();
38 40 self.contents = Arc::new(Vec::new());
39 41 }
@@ -44,7 +46,7 @@ impl BrowserState {
44 46 match self.backend.list_children_enriched(vfs_id, self.current_dir) {
45 47 Ok(nodes) => self.contents = Arc::new(nodes),
46 48 Err(e) => {
47 - eprintln!("Failed to list directory: {e}");
49 + error!("Failed to list directory: {e}");
48 50 self.status = "Failed to load contents".to_string();
49 51 self.contents = Arc::new(Vec::new());
50 52 }
@@ -112,7 +114,7 @@ impl BrowserState {
112 114 if let Some(node) = self.selected_node() {
113 115 if let Some(hash) = &node.node.sample_hash {
114 116 self.selected_tags = Arc::new(self.backend.get_sample_tags(hash).unwrap_or_else(|e| {
115 - eprintln!("Failed to load tags: {e}");
117 + warn!("Failed to load tags: {e}");
116 118 Vec::new()
117 119 }));
118 120 }
@@ -191,7 +193,7 @@ impl BrowserState {
191 193 if node.node.node_type == NodeType::Directory {
192 194 self.current_dir = Some(node.node.id);
193 195 self.breadcrumb = self.backend.get_breadcrumb(node.node.id).unwrap_or_else(|e| {
194 - eprintln!("Breadcrumb failed: {e}");
196 + warn!("Breadcrumb failed: {e}");
195 197 Vec::new()
196 198 });
197 199 self.selection.clear();
@@ -208,7 +210,7 @@ impl BrowserState {
208 210 self.current_dir = node.parent_id;
209 211 if let Some(pid) = node.parent_id {
210 212 self.breadcrumb = self.backend.get_breadcrumb(pid).unwrap_or_else(|e| {
211 - eprintln!("Breadcrumb failed: {e}");
213 + warn!("Breadcrumb failed: {e}");
212 214 Vec::new()
213 215 });
214 216 } else {
@@ -167,10 +167,18 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) {
167 167 row.col(|ui| {
168 168 let icon = match node.node.node_type {
169 169 NodeType::Directory => "\u{1F4C1} ",
170 + NodeType::Sample if node.cloud_only => "\u{2601} ",
170 171 NodeType::Sample => "\u{1F50A} ",
171 172 };
172 173 let label = format!("{}{}", icon, node.node.name);
173 - let resp = ui.selectable_label(selected, &label);
174 + let resp = if node.cloud_only {
175 + ui.selectable_label(
176 + selected,
177 + egui::RichText::new(&label).color(theme::text_muted()),
178 + )
179 + } else {
180 + ui.selectable_label(selected, &label)
181 + };
174 182
175 183 if resp.clicked() {
176 184 handle_click(state, row_idx, ui);
@@ -183,16 +191,18 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) {
183 191 state.enter_directory();
184 192 }
185 193 NodeType::Sample => {
186 - if let Some(hash) = &node.node.sample_hash {
187 - let hash = hash.clone();
188 - state.trigger_preview(&hash);
194 + if !node.cloud_only {
195 + if let Some(hash) = &node.node.sample_hash {
196 + let hash = hash.clone();
197 + state.trigger_preview(&hash);
198 + }
189 199 }
190 200 }
191 201 }
192 202 }
193 203
194 - // Drag source for instrument zone assignment
195 - if state.instrument_visible && node.node.node_type == NodeType::Sample {
204 + // Drag source for instrument zone assignment (not for cloud-only)
205 + if state.instrument_visible && node.node.node_type == NodeType::Sample && !node.cloud_only {
196 206 if let Some(hash) = &node.node.sample_hash {
197 207 resp.dnd_set_drag_payload(DragPayload {
198 208 hash: hash.to_string(),
@@ -286,7 +296,7 @@ pub fn draw_file_list(ui: &mut egui::Ui, state: &mut BrowserState) {
286 296
287 297 // Play button
288 298 row.col(|ui| {
289 - if node.node.node_type == NodeType::Sample {
299 + if node.node.node_type == NodeType::Sample && !node.cloud_only {
290 300 if let Some(hash) = &node.node.sample_hash {
291 301 let is_playing = state.previewing_hash.as_deref() == Some(hash)
292 302 && state.shared.preview.lock().playing;
@@ -375,12 +385,22 @@ fn draw_context_menu(
375 385 ) {
376 386 match node.node.node_type {
377 387 NodeType::Sample => {
378 - if ui.button("Preview").clicked() {
379 - if let Some(hash) = &node.node.sample_hash {
380 - let hash = hash.clone();
381 - state.trigger_preview(&hash);
388 + if node.cloud_only {
389 + ui.label(
390 + egui::RichText::new("Cloud-only sample")
391 + .color(theme::text_muted())
392 + .italics(),
393 + );
394 + ui.separator();
395 + }
396 + if !node.cloud_only {
397 + if ui.button("Preview").clicked() {
398 + if let Some(hash) = &node.node.sample_hash {
399 + let hash = hash.clone();
400 + state.trigger_preview(&hash);
401 + }
402 + ui.close_menu();
382 403 }
383 - ui.close_menu();
384 404 }
385 405 if ui.button("Copy Path").clicked() {
386 406 if let Some(path) = state.selected_sample_path() {
@@ -403,18 +423,20 @@ fn draw_context_menu(
403 423 }
404 424 ui.close_menu();
405 425 }
406 - if ui.button("Play as Instrument").clicked() {
407 - if let Some(hash) = &node.node.sample_hash {
408 - let hash = hash.clone();
409 - let name = node.node.name.clone();
410 - state.load_chromatic_sample(&hash, &name);
426 + if !node.cloud_only {
427 + if ui.button("Play as Instrument").clicked() {
428 + if let Some(hash) = &node.node.sample_hash {
429 + let hash = hash.clone();
430 + let name = node.node.name.clone();
431 + state.load_chromatic_sample(&hash, &name);
432 + }
433 + ui.close_menu();
434 + }
435 + if ui.button("Export...").clicked() {
436 + state.selection.set_single(row_idx);
437 + state.start_export_flow(Some(vec![node.node.id]));
438 + ui.close_menu();
411 439 }
412 - ui.close_menu();
413 - }
414 - if ui.button("Export...").clicked() {
415 - state.selection.set_single(row_idx);
416 - state.start_export_flow(Some(vec![node.node.id]));
417 - ui.close_menu();
418 440 }
419 441 ui.separator();
420 442 if ui.button("Delete").clicked() {
@@ -429,6 +451,15 @@ fn draw_context_menu(
429 451 state.enter_directory();
430 452 ui.close_menu();
431 453 }
454 + if ui.button("New Folder").clicked() {
455 + state.show_dir_create = true;
456 + state.dir_create_input.clear();
457 + ui.close_menu();
458 + }
459 + if ui.button("Rename").clicked() {
460 + state.dir_rename_target = Some((node.node.id, node.node.name.clone()));
461 + ui.close_menu();
462 + }
432 463 if ui.button("Export...").clicked() {
433 464 state.selection.set_single(row_idx);
434 465 state.start_export_flow(Some(vec![node.node.id]));
@@ -1,668 +0,0 @@
1 - //! Import/analysis workflow modal screens: configure import, progress, tagging, analysis config,
2 - //! analysis progress, and tag suggestion review.
3 -
4 - use std::collections::BTreeMap;
5 -
6 - use egui;
7 -
8 - use crate::import::ImportStrategy;
9 - use crate::state::{BrowserState, ImportMode};
10 - use audiofiles_core::tags;
11 -
12 - use super::theme;
13 -
14 - /// Draw the import configuration screen with strategy radio buttons.
15 - pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) {
16 - let source_display = match &state.import_mode {
17 - ImportMode::ConfigureImport { source, .. } => source.display().to_string(),
18 - _ => return,
19 - };
20 -
21 - egui::CentralPanel::default().show(ctx, |ui| {
22 - ui.heading("Import Folder");
23 - ui.add_space(8.0);
24 - ui.label(format!("Source: {source_display}"));
25 - ui.add_space(12.0);
26 -
27 - ui.label("Import strategy:");
28 - ui.add_space(4.0);
29 -
30 - let is_flat = matches!(
31 - &state.import_mode,
32 - ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. }
33 - );
34 - let is_new_vfs = matches!(
35 - &state.import_mode,
36 - ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. }
37 - );
38 - let is_merge = matches!(
39 - &state.import_mode,
40 - ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. }
41 - );
42 -
43 - let current_vfs_id = state.current_vfs_id();
44 - let current_dir = state.current_dir;
45 - if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat {
46 - if let ImportMode::ConfigureImport { ref mut strategy, .. } = state.import_mode {
47 - *strategy = ImportStrategy::Flat {
48 - vfs_id: current_vfs_id,
49 - parent_id: current_dir,
50 - };
51 - }
52 - }
53 -
54 - if ui.radio(is_new_vfs, "New VFS (preserve directory structure)").clicked() && !is_new_vfs {
55 - if let ImportMode::ConfigureImport {
56 - ref mut strategy,
57 - ref new_vfs_name,
58 - ..
59 - } = state.import_mode
60 - {
61 - *strategy = ImportStrategy::NewVfs {
62 - vfs_name: new_vfs_name.clone(),
63 - };
64 - }
65 - }
66 -
67 - if is_new_vfs {
68 - ui.indent("new_vfs_indent", |ui| {
69 - ui.horizontal(|ui| {
70 - ui.label("VFS name:");
71 - if let ImportMode::ConfigureImport {
72 - ref mut new_vfs_name,
73 - ref mut strategy,
74 - ..
75 - } = state.import_mode
76 - {
77 - if ui.text_edit_singleline(new_vfs_name).changed() {
78 - *strategy = ImportStrategy::NewVfs {
79 - vfs_name: new_vfs_name.clone(),
80 - };
81 - }
82 - }
83 - });
84 - });
85 - }
86 -
87 - if ui.radio(is_merge, "Merge into existing VFS").clicked() && !is_merge {
88 - if let ImportMode::ConfigureImport {
89 - ref mut strategy,
90 - ref available_vfs,
91 - selected_merge_vfs_idx,
92 - ..
93 - } = state.import_mode
94 - {
95 - let vfs = &available_vfs[selected_merge_vfs_idx];
96 - *strategy = ImportStrategy::MergeIntoVfs {
97 - vfs_id: vfs.id,
98 - parent_id: None,
99 - };
100 - }
101 - }
102 -
103 - if is_merge {
104 - ui.indent("merge_vfs_indent", |ui| {
105 - if let ImportMode::ConfigureImport {
106 - ref mut strategy,
107 - ref available_vfs,
108 - ref mut selected_merge_vfs_idx,
109 - ..
110 - } = state.import_mode
111 - {
112 - let current_name = available_vfs
113 - .get(*selected_merge_vfs_idx)
114 - .map(|v| v.name.as_str())
115 - .unwrap_or("(none)");
116 -
117 - egui::ComboBox::from_id_salt("merge_vfs_select")
118 - .selected_text(current_name)
119 - .show_ui(ui, |ui| {
120 - for (i, vfs) in available_vfs.iter().enumerate() {
121 - if ui
122 - .selectable_label(i == *selected_merge_vfs_idx, &vfs.name)
123 - .clicked()
124 - {
125 - *selected_merge_vfs_idx = i;
126 - *strategy = ImportStrategy::MergeIntoVfs {
127 - vfs_id: vfs.id,
128 - parent_id: None,
129 - };
130 - }
131 - }
132 - });
133 - }
134 - });
135 - }
136 -
137 - ui.add_space(16.0);
138 -
139 - ui.horizontal(|ui| {
140 - if ui.button("Import").clicked() {
141 - if let ImportMode::ConfigureImport {
142 - ref source,
143 - strategy: ref strat,
144 - ref new_vfs_name,
145 - ref available_vfs,
146 - selected_merge_vfs_idx,
147 - ..
148 - } = state.import_mode
149 - {
150 - let source = source.clone();
151 - let strategy = match strat {
152 - ImportStrategy::Flat { vfs_id, parent_id } => ImportStrategy::Flat {
153 - vfs_id: *vfs_id,
154 - parent_id: *parent_id,
155 - },
156 - ImportStrategy::NewVfs { .. } => ImportStrategy::NewVfs {
157 - vfs_name: new_vfs_name.clone(),
158 - },
159 - ImportStrategy::MergeIntoVfs { .. } => {
160 - let vfs = &available_vfs[selected_merge_vfs_idx];
161 - ImportStrategy::MergeIntoVfs {
162 - vfs_id: vfs.id,
163 - parent_id: None,
164 - }
165 - }
166 - };
167 - state.start_folder_import(source, strategy);
168 - }
169 - }
170 -
171 - if ui.button("Cancel").clicked() {
172 - state.import_mode = ImportMode::None;
173 - }
174 - });
175 - });
176 - }
177 -
178 - /// Draw the post-import folder tagging screen.
179 - pub fn draw_tag_folders(ctx: &egui::Context, state: &mut BrowserState) {
180 - let entry_count = match &state.import_mode {
181 - ImportMode::TagFolders { entries, .. } => entries.len(),
182 - _ => return,
183 - };
184 -
185 - egui::CentralPanel::default().show(ctx, |ui| {
186 - ui.heading("Tag Imported Folders");
187 - ui.add_space(4.0);
188 - ui.label("Assign tags to imported folders. Comma-separated. Applied to all samples within each folder.");
189 - ui.add_space(12.0);
190 -
191 - egui::ScrollArea::vertical()
192 - .auto_shrink([false, false])
193 - .show(ui, |ui| {
194 - for i in 0..entry_count {
195 - if let ImportMode::TagFolders {
196 - ref mut entries, ..
197 - } = state.import_mode
198 - {
199 - let entry = &mut entries[i];
200 - ui.horizontal(|ui| {
201 - ui.label(egui::RichText::new(&entry.folder.name).strong());
202 - ui.label(format!("({} samples)", entry.folder.samples.len()));
203 - });
204 -
205 - ui.horizontal(|ui| {
206 - ui.label("Tags:");
207 - ui.text_edit_singleline(&mut entry.tag_input);
208 - });
209 -
210 - if !entry.tag_input.trim().is_empty() {
211 - let invalid: Vec<&str> = entry
212 - .tag_input
213 - .split(',')
214 - .map(|s| s.trim())
215 - .filter(|s| !s.is_empty() && tags::validate_tag(s).is_err())
216 - .collect();
217 - if !invalid.is_empty() {
218 - ui.label(
219 - egui::RichText::new(format!(
220 - "Invalid: {}",
221 - invalid.join(", ")
222 - ))
223 - .color(theme::accent_red())
224 - .small(),
225 - );
226 - }
227 - }
228 -
229 - ui.add_space(8.0);
230 - }
231 - }
232 - });
233 -
234 - ui.add_space(8.0);
235 - ui.horizontal(|ui| {
236 - if ui.button("Apply Tags").clicked() {
237 - state.apply_folder_tags();
238 - }
239 - if ui.button("Skip").clicked() {
240 - state.skip_folder_tags();
241 - }
242 - });
243 - });
244 - }
245 -
246 - /// Draw the analysis configuration screen.
247 - pub fn draw_configure_analysis(ctx: &egui::Context, state: &mut BrowserState) {
248 - let (sample_count, mut config) = match &state.import_mode {
249 - ImportMode::ConfigureAnalysis {
250 - sample_hashes,
251 - config,
252 - } => (sample_hashes.len(), config.clone()),
253 - _ => return,
254 - };
255 -
256 - egui::CentralPanel::default().show(ctx, |ui| {
257 - ui.heading("Configure Analysis");
258 - ui.add_space(8.0);
259 - ui.label(format!("{sample_count} samples to analyze"));
260 - ui.add_space(12.0);
261 -
262 - ui.checkbox(&mut config.loudness, "Loudness (Peak, RMS, LUFS)");
263 - ui.checkbox(&mut config.bpm, "BPM Detection");
264 - ui.checkbox(&mut config.key, "Key Detection");
265 - ui.checkbox(&mut config.spectral, "Spectral Features");
266 - ui.checkbox(&mut config.classify, "Auto-classify (requires Spectral)");
267 - ui.checkbox(&mut config.loop_detect, "Loop Detection");
268 - ui.checkbox(&mut config.auto_suggest_tags, "Auto-suggest Tags");
269 - ui.checkbox(&mut config.fingerprint, "Fingerprint (duplicate detection)");
270 -
271 - // Classification depends on spectral features (centroid, flatness, ZCR, etc.),
272 - // so force spectral on when classify is checked.
273 - if config.classify && !config.spectral {
274 - config.spectral = true;
275 - }
276 -
277 - ui.add_space(16.0);
278 -
279 - ui.horizontal(|ui| {
280 - if ui.button("Run Analysis").clicked() {
281 - let hashes = match &state.import_mode {
282 - ImportMode::ConfigureAnalysis { sample_hashes, .. } => {
283 - sample_hashes.clone()
284 - }
285 - _ => Vec::new(),
286 - };
287 - state.run_analysis(hashes, config.clone());
288 - return;
289 - }
290 -
291 - if ui.button("Skip").clicked() {
292 - state.import_mode = ImportMode::None;
293 - state.status = "Analysis skipped".to_string();
294 - }
295 - });
296 - });
297 -
298 - if let ImportMode::ConfigureAnalysis {
299 - config: ref mut cfg,
300 - ..
301 - } = state.import_mode
302 - {
303 - *cfg = config;
304 - }
305 - }
306 -
307 - /// Draw the folder import progress screen.
308 - pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
309 - let (total, completed, current_name, walking) = match &state.import_mode {
310 - ImportMode::Importing {
311 - total,
312 - completed,
313 - current_name,
314 - walking,
315 - } => (*total, *completed, current_name.clone(), *walking),
316 - _ => return,
317 - };
318 -
319 - egui::CentralPanel::default().show(ctx, |ui| {
320 - ui.heading("Importing Folder...");
321 - ui.add_space(12.0);
322 -
323 - if walking {
324 - ui.horizontal(|ui| {
325 - ui.spinner();
326 - ui.label("Scanning for audio files...");
327 - });
328 - } else {
329 - let progress = if total > 0 {
330 - completed as f32 / total as f32
331 - } else {
332 - 0.0
333 - };
334 - let pct = (progress * 100.0) as u32;
335 - ui.add(
336 - egui::ProgressBar::new(progress)
337 - .text(format!("{pct}% \u{2014} {completed}/{total} files")),
338 - );
339 -
340 - ui.add_space(8.0);
341 - if !current_name.is_empty() {
342 - ui.label(format!("Current: {current_name}"));
343 - }
344 - }
345 -
346 - // Show accumulated error count with expandable details
347 - let err_count = state.import_errors.len();
348 - if err_count > 0 {
349 - ui.add_space(4.0);
350 - let label = format!(
351 - "{err_count} error{} (click to {})",
352 - if err_count == 1 { "" } else { "s" },
353 - if state.import_errors_expanded { "collapse" } else { "expand" },
354 - );
355 - if ui
356 - .add(egui::Label::new(
357 - egui::RichText::new(label).color(theme::accent_red()),
358 - ).sense(egui::Sense::click()))
359 - .clicked()
360 - {
361 - state.import_errors_expanded = !state.import_errors_expanded;
362 - }
363 -
364 - if state.import_errors_expanded {
365 - egui::ScrollArea::vertical()
366 - .max_height(120.0)
367 - .show(ui, |ui| {
368 - for err in &state.import_errors {
369 - ui.label(
370 - egui::RichText::new(err)
371 - .small()
372 - .color(theme::accent_red()),
373 - );
374 - }
375 - });
376 - }
377 - }
378 -
379 - ui.add_space(16.0);
380 - ui.horizontal(|ui| {
381 - if ui.button("Cancel").clicked() {
382 - state.cancel_import();
383 - }
384 - if err_count > 0
385 - && ui.button("Retry")
386 - .on_hover_text("Cancel and re-open import configuration")
387 - .clicked()
388 - {
389 - state.retry_import();
390 - }
391 - });
392 - });
393 -
394 - ctx.request_repaint();
395 - }
396 -
397 - /// Draw the analysis progress screen.
398 - pub fn draw_analysis_progress(ctx: &egui::Context, state: &mut BrowserState) {
399 - let (completed, total, current_name) = match &state.import_mode {
400 - ImportMode::Analyzing {
401 - completed,
402 - total,
403 - current_name,
404 - } => (*completed, *total, current_name.clone()),
405 - _ => return,
406 - };
407 -
408 - egui::CentralPanel::default().show(ctx, |ui| {
409 - ui.heading("Analyzing Samples...");
410 - ui.add_space(12.0);
411 -
412 - let progress = if total > 0 {
413 - completed as f32 / total as f32
414 - } else {
415 - 0.0
416 - };
417 - let pct = (progress * 100.0) as u32;
418 - ui.add(
419 - egui::ProgressBar::new(progress)
420 - .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
421 - );
422 -
423 - ui.add_space(8.0);
424 - if !current_name.is_empty() {
425 - ui.label(format!("Current: {current_name}"));
426 - }
427 -
428 - // Show accumulated error count with expandable details
429 - let err_count = state.import_errors.len();
430 - if err_count > 0 {
431 - ui.add_space(4.0);
432 - let label = format!(
433 - "{err_count} error{} (click to {})",
434 - if err_count == 1 { "" } else { "s" },
435 - if state.import_errors_expanded { "collapse" } else { "expand" },
436 - );
437 - if ui
438 - .add(egui::Label::new(
439 - egui::RichText::new(label).color(theme::accent_red()),
440 - ).sense(egui::Sense::click()))
441 - .clicked()
442 - {
443 - state.import_errors_expanded = !state.import_errors_expanded;
444 - }
445 -
446 - if state.import_errors_expanded {
447 - egui::ScrollArea::vertical()
448 - .max_height(120.0)
449 - .show(ui, |ui| {
450 - for err in &state.import_errors {
451 - ui.label(
452 - egui::RichText::new(err)
453 - .small()
454 - .color(theme::accent_red()),
455 - );
456 - }
457 - });
458 - }
459 - }
460 -
461 - ui.add_space(16.0);
462 - ui.horizontal(|ui| {
463 - if ui.button("Cancel").clicked() {
464 - state.cancel_analysis();
465 - }
466 - if err_count > 0
467 - && ui.button("Retry")
468 - .on_hover_text("Cancel and restart analysis")
469 - .clicked()
470 - {
471 - state.retry_analysis();
472 - }
473 - });
474 - });
475 -
476 - ctx.request_repaint();
477 - }
478 -
479 - /// Draw the tag review screen.
480 - pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) {
481 - let (item_count, total_suggestions, accepted_count, _current_idx) =
482 - match &state.import_mode {
483 - ImportMode::ReviewSuggestions { items, current_idx } => {
484 - let total: usize = items.iter().map(|i| i.suggestions.len()).sum();
485 - let accepted: usize = items
486 - .iter()
487 - .flat_map(|i| &i.suggestions)
488 - .filter(|s| s.accepted)
489 - .count();
490 - (items.len(), total, accepted, *current_idx)
491 - }
492 - _ => return,
493 - };
494 -
495 - egui::TopBottomPanel::top("review_header").show(ctx, |ui| {
496 - ui.horizontal(|ui| {
497 - ui.heading("Review Tag Suggestions");
498 - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
499 - if ui.button("Reject All").clicked() {
500 - if let ImportMode::ReviewSuggestions { ref mut items, .. } =
Lines truncated
@@ -0,0 +1,229 @@
1 + use egui;
2 +
3 + use crate::import::ImportStrategy;
4 + use crate::state::{BrowserState, ImportMode};
5 +
6 + /// Draw the import configuration screen with strategy radio buttons.
7 + pub fn draw_configure_import(ctx: &egui::Context, state: &mut BrowserState) {
8 + let source_display = match &state.import_mode {
9 + ImportMode::ConfigureImport { source, .. } => source.display().to_string(),
10 + _ => return,
11 + };
12 +
13 + egui::CentralPanel::default().show(ctx, |ui| {
14 + ui.heading("Import Folder");
15 + ui.add_space(8.0);
16 + ui.label(format!("Source: {source_display}"));
17 + ui.add_space(12.0);
18 +
19 + ui.label("Import strategy:");
20 + ui.add_space(4.0);
21 +
22 + let is_flat = matches!(
23 + &state.import_mode,
24 + ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. }
25 + );
26 + let is_new_vfs = matches!(
27 + &state.import_mode,
28 + ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. }
29 + );
30 + let is_merge = matches!(
31 + &state.import_mode,
32 + ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. }
33 + );
34 +
35 + let current_vfs_id = state.current_vfs_id();
36 + let current_dir = state.current_dir;
37 + if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat {
38 + if let ImportMode::ConfigureImport { ref mut strategy, .. } = state.import_mode {
39 + *strategy = ImportStrategy::Flat {
40 + vfs_id: current_vfs_id,
41 + parent_id: current_dir,
42 + };
43 + }
44 + }
45 +
46 + if ui.radio(is_new_vfs, "New VFS (preserve directory structure)").clicked() && !is_new_vfs {
47 + if let ImportMode::ConfigureImport {
48 + ref mut strategy,
49 + ref new_vfs_name,
50 + ..
51 + } = state.import_mode
52 + {
53 + *strategy = ImportStrategy::NewVfs {
54 + vfs_name: new_vfs_name.clone(),
55 + };
56 + }
57 + }
58 +
59 + if is_new_vfs {
60 + ui.indent("new_vfs_indent", |ui| {
61 + ui.horizontal(|ui| {
62 + ui.label("VFS name:");
63 + if let ImportMode::ConfigureImport {
64 + ref mut new_vfs_name,
65 + ref mut strategy,
66 + ..
67 + } = state.import_mode
68 + {
69 + if ui.text_edit_singleline(new_vfs_name).changed() {
70 + *strategy = ImportStrategy::NewVfs {
71 + vfs_name: new_vfs_name.clone(),
72 + };
73 + }
74 + }
75 + });
76 + });
77 + }
78 +
79 + if ui.radio(is_merge, "Merge into existing VFS").clicked() && !is_merge {
80 + if let ImportMode::ConfigureImport {
81 + ref mut strategy,
82 + ref available_vfs,
83 + selected_merge_vfs_idx,
84 + ..
85 + } = state.import_mode
86 + {
87 + let vfs = &available_vfs[selected_merge_vfs_idx];
88 + *strategy = ImportStrategy::MergeIntoVfs {
89 + vfs_id: vfs.id,
90 + parent_id: None,
91 + };
92 + }
93 + }
94 +
95 + if is_merge {
96 + ui.indent("merge_vfs_indent", |ui| {
97 + if let ImportMode::ConfigureImport {
98 + ref mut strategy,
99 + ref available_vfs,
100 + ref mut selected_merge_vfs_idx,
101 + ..
102 + } = state.import_mode
103 + {
104 + let current_name = available_vfs
105 + .get(*selected_merge_vfs_idx)
106 + .map(|v| v.name.as_str())
107 + .unwrap_or("(none)");
108 +
109 + egui::ComboBox::from_id_salt("merge_vfs_select")
110 + .selected_text(current_name)
111 + .show_ui(ui, |ui| {
112 + for (i, vfs) in available_vfs.iter().enumerate() {
113 + if ui
114 + .selectable_label(i == *selected_merge_vfs_idx, &vfs.name)
115 + .clicked()
116 + {
117 + *selected_merge_vfs_idx = i;
118 + *strategy = ImportStrategy::MergeIntoVfs {
119 + vfs_id: vfs.id,
120 + parent_id: None,
121 + };
122 + }
123 + }
124 + });
125 + }
126 + });
127 + }
128 +
129 + ui.add_space(16.0);
130 +
131 + ui.horizontal(|ui| {
132 + if ui.button("Import").clicked() {
133 + if let ImportMode::ConfigureImport {
134 + ref source,
135 + strategy: ref strat,
136 + ref new_vfs_name,
137 + ref available_vfs,
138 + selected_merge_vfs_idx,
139 + ..
140 + } = state.import_mode
141 + {
142 + let source = source.clone();
143 + let strategy = match strat {
144 + ImportStrategy::Flat { vfs_id, parent_id } => ImportStrategy::Flat {
145 + vfs_id: *vfs_id,
146 + parent_id: *parent_id,
147 + },
148 + ImportStrategy::NewVfs { .. } => ImportStrategy::NewVfs {
149 + vfs_name: new_vfs_name.clone(),
150 + },
151 + ImportStrategy::MergeIntoVfs { .. } => {
152 + let vfs = &available_vfs[selected_merge_vfs_idx];
153 + ImportStrategy::MergeIntoVfs {
154 + vfs_id: vfs.id,
155 + parent_id: None,
156 + }
157 + }
158 + };
159 + state.start_folder_import(source, strategy);
160 + }
161 + }
162 +
163 + if ui.button("Cancel").clicked() {
164 + state.import_mode = ImportMode::None;
165 + }
166 + });
167 + });
168 + }
169 +
170 + /// Draw the analysis configuration screen.
171 + pub fn draw_configure_analysis(ctx: &egui::Context, state: &mut BrowserState) {
172 + let (sample_count, mut config) = match &state.import_mode {
173 + ImportMode::ConfigureAnalysis {
174 + sample_hashes,
175 + config,
176 + } => (sample_hashes.len(), config.clone()),
177 + _ => return,
178 + };
179 +
180 + egui::CentralPanel::default().show(ctx, |ui| {
181 + ui.heading("Configure Analysis");
182 + ui.add_space(8.0);
183 + ui.label(format!("{sample_count} samples to analyze"));
184 + ui.add_space(12.0);
185 +
186 + ui.checkbox(&mut config.loudness, "Loudness (Peak, RMS, LUFS)");
187 + ui.checkbox(&mut config.bpm, "BPM Detection");
188 + ui.checkbox(&mut config.key, "Key Detection");
189 + ui.checkbox(&mut config.spectral, "Spectral Features");
190 + ui.checkbox(&mut config.classify, "Auto-classify (requires Spectral)");
191 + ui.checkbox(&mut config.loop_detect, "Loop Detection");
192 + ui.checkbox(&mut config.auto_suggest_tags, "Auto-suggest Tags");
193 + ui.checkbox(&mut config.fingerprint, "Fingerprint (duplicate detection)");
194 +
195 + // Classification depends on spectral features (centroid, flatness, ZCR, etc.),
196 + // so force spectral on when classify is checked.
197 + if config.classify && !config.spectral {
198 + config.spectral = true;
199 + }
200 +
201 + ui.add_space(16.0);
202 +
203 + ui.horizontal(|ui| {
204 + if ui.button("Run Analysis").clicked() {
205 + let hashes = match &state.import_mode {
206 + ImportMode::ConfigureAnalysis { sample_hashes, .. } => {
207 + sample_hashes.clone()
208 + }
209 + _ => Vec::new(),
210 + };
211 + state.run_analysis(hashes, config.clone());
212 + return;
213 + }
214 +
215 + if ui.button("Skip").clicked() {
216 + state.import_mode = ImportMode::None;
217 + state.status = "Analysis skipped".to_string();
218 + }
219 + });
220 + });
221 +
222 + if let ImportMode::ConfigureAnalysis {
223 + config: ref mut cfg,
224 + ..
225 + } = state.import_mode
226 + {
227 + *cfg = config;
228 + }
229 + }
@@ -0,0 +1,10 @@
1 + //! Import/analysis workflow modal screens: configure import, progress, tagging, analysis config,
2 + //! analysis progress, and tag suggestion review.
3 +
4 + mod configure;
5 + mod progress;
6 + mod tagging;
7 +
8 + pub use configure::{draw_configure_analysis, draw_configure_import};
9 + pub use progress::{draw_analysis_progress, draw_import_progress};
10 + pub use tagging::{draw_review_suggestions, draw_tag_folders};
@@ -0,0 +1,177 @@
1 + use egui;
2 +
3 + use crate::state::{BrowserState, ImportMode};
4 +
5 + use super::super::theme;
6 +
7 + /// Draw the folder import progress screen.
8 + pub fn draw_import_progress(ctx: &egui::Context, state: &mut BrowserState) {
9 + let (total, completed, current_name, walking) = match &state.import_mode {
10 + ImportMode::Importing {
11 + total,
12 + completed,
13 + current_name,
14 + walking,
15 + } => (*total, *completed, current_name.clone(), *walking),
16 + _ => return,
17 + };
18 +
19 + egui::CentralPanel::default().show(ctx, |ui| {
20 + ui.heading("Importing Folder...");
21 + ui.add_space(12.0);
22 +
23 + if walking {
24 + ui.horizontal(|ui| {
25 + ui.spinner();
26 + ui.label("Scanning for audio files...");
27 + });
28 + } else {
29 + let progress = if total > 0 {
30 + completed as f32 / total as f32
31 + } else {
32 + 0.0
33 + };
34 + let pct = (progress * 100.0) as u32;
35 + ui.add(
36 + egui::ProgressBar::new(progress)
37 + .text(format!("{pct}% \u{2014} {completed}/{total} files")),
38 + );
39 +
40 + ui.add_space(8.0);
41 + if !current_name.is_empty() {
42 + ui.label(format!("Current: {current_name}"));
43 + }
44 + }
45 +
46 + // Show accumulated error count with expandable details
47 + let err_count = state.import_errors.len();
48 + if err_count > 0 {
49 + ui.add_space(4.0);
50 + let label = format!(
51 + "{err_count} error{} (click to {})",
52 + if err_count == 1 { "" } else { "s" },
53 + if state.import_errors_expanded { "collapse" } else { "expand" },
54 + );
55 + if ui
56 + .add(egui::Label::new(
57 + egui::RichText::new(label).color(theme::accent_red()),
58 + ).sense(egui::Sense::click()))
59 + .clicked()
60 + {
61 + state.import_errors_expanded = !state.import_errors_expanded;
62 + }
63 +
64 + if state.import_errors_expanded {
65 + egui::ScrollArea::vertical()
66 + .max_height(120.0)
67 + .show(ui, |ui| {
68 + for err in &state.import_errors {
69 + ui.label(
70 + egui::RichText::new(err)
71 + .small()
72 + .color(theme::accent_red()),
73 + );
74 + }
75 + });
76 + }
77 + }
78 +
79 + ui.add_space(16.0);
80 + ui.horizontal(|ui| {
81 + if ui.button("Cancel").clicked() {
82 + state.cancel_import();
83 + }
84 + if err_count > 0
85 + && ui.button("Retry")
86 + .on_hover_text("Cancel and re-open import configuration")
87 + .clicked()
88 + {
89 + state.retry_import();
90 + }
91 + });
92 + });
93 +
94 + ctx.request_repaint();
95 + }
96 +
97 + /// Draw the analysis progress screen.
98 + pub fn draw_analysis_progress(ctx: &egui::Context, state: &mut BrowserState) {
99 + let (completed, total, current_name) = match &state.import_mode {
100 + ImportMode::Analyzing {
101 + completed,
102 + total,
103 + current_name,
104 + } => (*completed, *total, current_name.clone()),
105 + _ => return,
106 + };
107 +
108 + egui::CentralPanel::default().show(ctx, |ui| {
109 + ui.heading("Analyzing Samples...");
110 + ui.add_space(12.0);
111 +
112 + let progress = if total > 0 {
113 + completed as f32 / total as f32
114 + } else {
115 + 0.0
116 + };
117 + let pct = (progress * 100.0) as u32;
118 + ui.add(
119 + egui::ProgressBar::new(progress)
120 + .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
121 + );
122 +
123 + ui.add_space(8.0);
124 + if !current_name.is_empty() {
125 + ui.label(format!("Current: {current_name}"));
126 + }
127 +
128 + // Show accumulated error count with expandable details
129 + let err_count = state.import_errors.len();
130 + if err_count > 0 {
131 + ui.add_space(4.0);
132 + let label = format!(
133 + "{err_count} error{} (click to {})",
134 + if err_count == 1 { "" } else { "s" },
135 + if state.import_errors_expanded { "collapse" } else { "expand" },
136 + );
137 + if ui
138 + .add(egui::Label::new(
139 + egui::RichText::new(label).color(theme::accent_red()),
140 + ).sense(egui::Sense::click()))
141 + .clicked()
142 + {
143 + state.import_errors_expanded = !state.import_errors_expanded;
144 + }
145 +
146 + if state.import_errors_expanded {
147 + egui::ScrollArea::vertical()
148 + .max_height(120.0)
149 + .show(ui, |ui| {
150 + for err in &state.import_errors {
151 + ui.label(
152 + egui::RichText::new(err)
153 + .small()
154 + .color(theme::accent_red()),
155 + );
156 + }
157 + });
158 + }
159 + }
160 +
161 + ui.add_space(16.0);
162 + ui.horizontal(|ui| {
163 + if ui.button("Cancel").clicked() {
164 + state.cancel_analysis();
165 + }
166 + if err_count > 0
167 + && ui.button("Retry")
168 + .on_hover_text("Cancel and restart analysis")
169 + .clicked()
170 + {
171 + state.retry_analysis();
172 + }
173 + });
174 + });
175 +
176 + ctx.request_repaint();
177 + }
@@ -0,0 +1,267 @@
1 + use std::collections::BTreeMap;
2 +
3 + use egui;
4 +
5 + use crate::state::{BrowserState, ImportMode};
6 + use audiofiles_core::tags;
7 +
8 + use super::super::theme;
9 +
10 + /// Draw the post-import folder tagging screen.
11 + pub fn draw_tag_folders(ctx: &egui::Context, state: &mut BrowserState) {
12 + let entry_count = match &state.import_mode {
13 + ImportMode::TagFolders { entries, .. } => entries.len(),
14 + _ => return,
15 + };
16 +
17 + egui::CentralPanel::default().show(ctx, |ui| {
18 + ui.heading("Tag Imported Folders");
19 + ui.add_space(4.0);
20 + ui.label("Assign tags to imported folders. Comma-separated. Applied to all samples within each folder.");
21 + ui.add_space(12.0);
22 +
23 + egui::ScrollArea::vertical()
24 + .auto_shrink([false, false])
25 + .show(ui, |ui| {
26 + for i in 0..entry_count {
27 + if let ImportMode::TagFolders {
28 + ref mut entries, ..
29 + } = state.import_mode
30 + {
31 + let entry = &mut entries[i];
32 + ui.horizontal(|ui| {
33 + ui.label(egui::RichText::new(&entry.folder.name).strong());
34 + ui.label(format!("({} samples)", entry.folder.samples.len()));
35 + });
36 +
37 + ui.horizontal(|ui| {
38 + ui.label("Tags:");
39 + ui.text_edit_singleline(&mut entry.tag_input);
40 + });
41 +
42 + if !entry.tag_input.trim().is_empty() {
43 + let invalid: Vec<&str> = entry
44 + .tag_input
45 + .split(',')
46 + .map(|s| s.trim())
47 + .filter(|s| !s.is_empty() && tags::validate_tag(s).is_err())
48 + .collect();
49 + if !invalid.is_empty() {
50 + ui.label(
51 + egui::RichText::new(format!(
52 + "Invalid: {}",
53 + invalid.join(", ")
54 + ))
55 + .color(theme::accent_red())
56 + .small(),
57 + );
58 + }
59 + }
60 +
61 + ui.add_space(8.0);
62 + }
63 + }
64 + });
65 +
66 + ui.add_space(8.0);
67 + ui.horizontal(|ui| {
68 + if ui.button("Apply Tags").clicked() {
69 + state.apply_folder_tags();
70 + }
71 + if ui.button("Skip").clicked() {
72 + state.skip_folder_tags();
73 + }
74 + });
75 + });
76 + }
77 +
78 + /// Draw the tag review screen.
79 + pub fn draw_review_suggestions(ctx: &egui::Context, state: &mut BrowserState) {
80 + let (item_count, total_suggestions, accepted_count, _current_idx) =
81 + match &state.import_mode {
82 + ImportMode::ReviewSuggestions { items, current_idx } => {
83 + let total: usize = items.iter().map(|i| i.suggestions.len()).sum();
84 + let accepted: usize = items
85 + .iter()
86 + .flat_map(|i| &i.suggestions)
87 + .filter(|s| s.accepted)
88 + .count();
89 + (items.len(), total, accepted, *current_idx)
90 + }
91 + _ => return,
92 + };
93 +
94 + egui::TopBottomPanel::top("review_header").show(ctx, |ui| {
95 + ui.horizontal(|ui| {
96 + ui.heading("Review Tag Suggestions");
97 + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
98 + if ui.button("Reject All").clicked() {
99 + if let ImportMode::ReviewSuggestions { ref mut items, .. } =
100 + state.import_mode
101 + {
102 + for item in items.iter_mut() {
103 + for sug in &mut item.suggestions {
104 + sug.accepted = false;
105 + }
106 + }
107 + }
108 + }
109 + if ui.button("Accept All").clicked() {
110 + if let ImportMode::ReviewSuggestions { ref mut items, .. } =
111 + state.import_mode
112 + {
113 + for item in items.iter_mut() {
114 + for sug in &mut item.suggestions {
115 + sug.accepted = true;
116 + }
117 + }
118 + }
119 + }
120 + });
121 + });
122 + ui.label(format!(
123 + "{item_count} samples, {total_suggestions} suggestions ({accepted_count} accepted)"
124 + ));
125 +
126 + // Aggregate stats: summarise the batch so the user can eyeball whether
127 + // the analysis results look sane before committing tags. Shows classification
128 + // distribution, BPM range, and top-3 detected keys.
129 + if let ImportMode::ReviewSuggestions { ref items, .. } = state.import_mode {
130 + let mut class_counts: BTreeMap<&str, usize> = BTreeMap::new();
131 + let mut bpm_min = f64::MAX;
132 + let mut bpm_max = f64::MIN;
133 + let mut keys: BTreeMap<String, usize> = BTreeMap::new();
134 +
135 + for item in items {
136 + if let Some(ref c) = item.result.classification {
137 + *class_counts.entry(c.as_str()).or_default() += 1;
138 + }
139 + if let Some(bpm) = item.result.bpm {
140 + bpm_min = bpm_min.min(bpm);
141 + bpm_max = bpm_max.max(bpm);
142 + }
143 + if let Some(ref k) = item.result.musical_key {
144 + *keys.entry(k.clone()).or_default() += 1;
145 + }
146 + }
147 +
148 + if !class_counts.is_empty() {
149 + ui.horizontal_wrapped(|ui| {
150 + for (class, count) in &class_counts {
151 + ui.label(format!("{class}: {count}"));
152 + }
153 + });
154 + }
155 + if bpm_min < bpm_max {
156 + ui.label(format!("BPM: {:.0} - {:.0}", bpm_min, bpm_max));
157 + } else if bpm_min != f64::MAX {
158 + ui.label(format!("BPM: {:.0}", bpm_min));
159 + }
160 + if !keys.is_empty() {
161 + // BTreeMap is sorted, so .take(3) gives the first 3 alphabetically.
162 + let top_keys: Vec<_> = keys.iter().take(3).collect();
163 + let label = top_keys
164 + .iter()
165 + .map(|(k, c)| format!("{k} ({c})"))
166 + .collect::<Vec<_>>()
167 + .join(", ");
168 + ui.label(format!("Keys: {label}"));
169 + }
170 + }
171 + });
172 +
173 + egui::TopBottomPanel::bottom("review_footer").show(ctx, |ui| {
174 + ui.add_space(4.0);
175 + ui.horizontal(|ui| {
176 + if ui.button("Apply Selected Tags").clicked() {
177 + state.apply_accepted_suggestions();
178 + return;
179 + }
180 + if ui.button("Cancel").clicked() {
181 + state.import_mode = ImportMode::None;
182 + state.status = "Suggestions discarded".to_string();
183 + }
184 + });
185 + ui.add_space(2.0);
186 + });
187 +
188 + egui::SidePanel::left("review_samples")
189 + .resizable(true)
190 + .default_width(200.0)
191 + .show(ctx, |ui| {
192 + ui.label("Samples");
193 + ui.separator();
194 +
195 + egui::ScrollArea::vertical().show(ui, |ui| {
196 + if let ImportMode::ReviewSuggestions {
197 + ref items,
198 + ref mut current_idx,
199 + ..
200 + } = state.import_mode
201 + {
202 + for (i, item) in items.iter().enumerate() {
203 + let sug_count = item.suggestions.len();
204 + let label = format!("{} {}", item.name, sug_count);
205 + let selected = i == *current_idx;
206 + if ui.selectable_label(selected, &label).clicked() {
207 + *current_idx = i;
208 + }
209 + }
210 + }
211 + });
212 + });
213 +
214 + egui::CentralPanel::default().show(ctx, |ui| {
215 + if let ImportMode::ReviewSuggestions {
216 + ref mut items,
217 + current_idx,
218 + ..
219 + } = state.import_mode
220 + {
221 + if let Some(item) = items.get_mut(current_idx) {
222 + ui.heading(&item.name);
223 + ui.horizontal(|ui| {
224 + ui.label(format!("{:.2}s", item.result.duration));
225 + ui.label(format!("{}Hz", item.result.sample_rate));
226 + if let Some(peak) = item.result.peak_db {
227 + ui.label(format!("Peak: {peak:.1}dB"));
228 + }
229 + });
230 + ui.horizontal(|ui| {
231 + if let Some(bpm) = item.result.bpm {
232 + ui.label(format!("{bpm:.1} BPM"));
233 + }
234 + if let Some(ref key) = item.result.musical_key {
235 + ui.label(key);
236 + }
237 + if let Some(ref class) = item.result.classification {
238 + ui.label(
239 + egui::RichText::new(class.as_str())
240 + .color(theme::accent_green()),
241 + );
242 + }
243 + });
244 +
245 + ui.separator();
246 +
247 + egui::ScrollArea::vertical().show(ui, |ui| {
248 + for sug in &mut item.suggestions {
249 + ui.horizontal(|ui| {
250 + ui.checkbox(&mut sug.accepted, "");
251 + ui.label(egui::RichText::new(&sug.suggestion.tag).strong());
252 + ui.label(format!("{:.0}%", sug.suggestion.confidence * 100.0));
253 + });
254 + ui.indent(sug.suggestion.tag.as_str(), |ui| {
255 + ui.label(
256 + egui::RichText::new(&sug.suggestion.reason)
257 + .small()
258 + .color(theme::text_muted()),
259 + );
260 + });
261 + ui.add_space(4.0);
262 + }
263 + });
264 + }
265 + }
266 + });
267 + }
@@ -367,3 +367,182 @@ fn draw_bulk_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
367 367 state.close_bulk_modal();
368 368 }
369 369 }
370 +
371 + /// Draw the "New Library" modal: text input for library name.
372 + pub fn draw_vfs_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
373 + let mut should_close = false;
374 + let mut should_create = false;
375 +
376 + egui::Window::new("New Library")
377 + .collapsible(false)
378 + .resizable(false)
379 + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
380 + .show(ctx, |ui| {
381 + ui.label("Library name:");
382 + let resp = ui.text_edit_singleline(&mut state.vfs_create_input);
383 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
384 + should_create = true;
385 + }
386 + ui.add_space(8.0);
387 + ui.horizontal(|ui| {
388 + if ui.button("Create").clicked() {
389 + should_create = true;
390 + }
391 + if ui.button("Cancel").clicked() {
392 + should_close = true;
393 + }
394 + });
395 + });
396 +
397 + if should_create {
398 + let name = state.vfs_create_input.trim().to_string();
399 + if !name.is_empty() {
400 + match state.backend.create_vfs(&name) {
401 + Ok(_) => {
402 + state.refresh_vfs_list();
403 + state.status = format!("Created library: {name}");
404 + }
405 + Err(e) => state.status = format!("Failed to create library: {e}"),
406 + }
407 + }
408 + state.show_vfs_create = false;
409 + } else if should_close {
410 + state.show_vfs_create = false;
411 + }
412 + }
413 +
414 + /// Draw the "Rename Library" modal: text input pre-filled with current name.
415 + pub fn draw_vfs_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
416 + let mut should_close = false;
417 + let mut should_save = false;
418 +
419 + egui::Window::new("Rename Library")
420 + .collapsible(false)
421 + .resizable(false)
422 + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
423 + .show(ctx, |ui| {
424 + if let Some((_, ref mut name_buf)) = state.vfs_rename_target {
425 + ui.label("New name:");
426 + let resp = ui.text_edit_singleline(name_buf);
427 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
428 + should_save = true;
429 + }
430 + ui.add_space(8.0);
431 + ui.horizontal(|ui| {
432 + if ui.button("Save").clicked() {
433 + should_save = true;
434 + }
435 + if ui.button("Cancel").clicked() {
436 + should_close = true;
437 + }
438 + });
439 + }
440 + });
441 +
442 + if should_save {
443 + if let Some((vfs_id, name_buf)) = state.vfs_rename_target.take() {
444 + let new_name = name_buf.trim().to_string();
445 + if !new_name.is_empty() {
446 + match state.backend.rename_vfs(vfs_id, &new_name) {
447 + Ok(()) => {
448 + state.refresh_vfs_list();
449 + state.status = format!("Renamed library to: {new_name}");
450 + }
451 + Err(e) => state.status = format!("Failed to rename library: {e}"),
452 + }
453 + }
454 + }
455 + } else if should_close {
456 + state.vfs_rename_target = None;
457 + }
458 + }
459 +
460 + /// Draw the "New Folder" modal: text input for folder name.
461 + pub fn draw_dir_create_modal(ctx: &egui::Context, state: &mut BrowserState) {
462 + let mut should_close = false;
463 + let mut should_create = false;
464 +
465 + egui::Window::new("New Folder")
466 + .collapsible(false)
467 + .resizable(false)
468 + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
469 + .show(ctx, |ui| {
470 + ui.label("Folder name:");
471 + let resp = ui.text_edit_singleline(&mut state.dir_create_input);
472 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
473 + should_create = true;
474 + }
475 + ui.add_space(8.0);
476 + ui.horizontal(|ui| {
477 + if ui.button("Create").clicked() {
478 + should_create = true;
479 + }
480 + if ui.button("Cancel").clicked() {
481 + should_close = true;
482 + }
483 + });
484 + });
485 +
486 + if should_create {
487 + let name = state.dir_create_input.trim().to_string();
488 + if !name.is_empty() {
489 + let vfs_id = state.vfs_list[state.current_vfs_idx].id;
490 + match state.backend.create_directory(vfs_id, state.current_dir, &name) {
491 + Ok(_) => {
492 + state.refresh_contents();
493 + state.status = format!("Created folder: {name}");
494 + }
495 + Err(e) => state.status = format!("Failed to create folder: {e}"),
496 + }
497 + }
498 + state.show_dir_create = false;
499 + } else if should_close {
500 + state.show_dir_create = false;
501 + }
502 + }
503 +
504 + /// Draw the "Rename Folder" modal: text input pre-filled with current name.
505 + pub fn draw_dir_rename_modal(ctx: &egui::Context, state: &mut BrowserState) {
506 + let mut should_close = false;
507 + let mut should_save = false;
508 +
509 + egui::Window::new("Rename")
510 + .collapsible(false)
511 + .resizable(false)
512 + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0])
513 + .show(ctx, |ui| {
514 + if let Some((_, ref mut name_buf)) = state.dir_rename_target {
515 + ui.label("New name:");
516 + let resp = ui.text_edit_singleline(name_buf);
517 + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
518 + should_save = true;
519 + }
520 + ui.add_space(8.0);
521 + ui.horizontal(|ui| {
522 + if ui.button("Save").clicked() {
523 + should_save = true;
524 + }
525 + if ui.button("Cancel").clicked() {
526 + should_close = true;
527 + }
528 + });
529 + }
530 + });
531 +
532 + if should_save {
533 + if let Some((node_id, name_buf)) = state.dir_rename_target.take() {
534 + let new_name = name_buf.trim().to_string();
535 + if !new_name.is_empty() {
536 + match state.backend.rename_node(node_id, &new_name) {
537 + Ok(()) => {
538 + state.refresh_contents();
539 + state.status = format!("Renamed to: {new_name}");
540 + }
541 + Err(e) => state.status = format!("Failed to rename: {e}"),
542 + }
543 + }
544 + }
545 + } else if should_close {
546 + state.dir_rename_target = None;
547 + }
548 + }
@@ -11,19 +11,39 @@ pub fn draw_sidebar(ui: &mut egui::Ui, state: &mut BrowserState) {
11 11 ui.separator();
12 12
13 13 // VFS roots as vertical list
14 - for (i, vfs) in state.vfs_list.clone().iter().enumerate() {
14 + let vfs_list = state.vfs_list.clone();
15 + let vfs_count = vfs_list.len();
16 + for (i, vfs) in vfs_list.iter().enumerate() {
15 17 let active = i == state.current_vfs_idx;
16 18 let label = if active {
17 19 egui::RichText::new(&vfs.name).strong().color(theme::accent_blue())
18 20 } else {
19 21 egui::RichText::new(&vfs.name).color(theme::text_primary())
20 22 };
21 - if ui.selectable_label(active, label)
22 - .on_hover_text(format!("Switch to {} library", vfs.name))
23 - .clicked() && !active
24 - {
23 + let resp = ui.selectable_label(active, label)
24 + .on_hover_text(format!("Switch to {} library", vfs.name));
25 + if resp.clicked() && !active {
25 26 state.select_vfs(i);
26 27 }
28 + let vfs_id = vfs.id;
29 + let vfs_name = vfs.name.clone();
30 + resp.context_menu(|ui| {
31 + if ui.button("Rename").clicked() {
32 + state.vfs_rename_target = Some((vfs_id, vfs_name.clone()));
33 + ui.close_menu();
34 + }
35 + if vfs_count > 1 {
36 + if ui.button("Delete").clicked() {
37 + state.pending_confirm = Some(crate::state::ConfirmAction::DeleteVfs { vfs_id, vfs_name });
38 + ui.close_menu();
39 + }
40 + }
41 + });
42 + }
43 +
44 + if ui.small_button("+").on_hover_text("New library").clicked() {
45 + state.show_vfs_create = true;
46 + state.vfs_create_input.clear();
27 47 }
28 48
29 49 ui.add_space(12.0);
@@ -1,6 +1,7 @@
1 1 //! Sync settings panel: egui Window overlay with 4 states matching the SyncKit flow.
2 2
3 3 use egui;
4 + use tracing::{debug, error, warn};
4 5
5 6 use audiofiles_sync::{SyncManager, SyncState, SyncStatus};
6 7
@@ -78,7 +79,7 @@ fn draw_disconnected(
78 79 state.sync_auth_code_input.clear();
79 80 }
80 81 Err(e) => {
81 - eprintln!("Failed to start auth: {e}");
82 + error!("Failed to start auth: {e}");
82 83 }
83 84 }
84 85 }
@@ -102,7 +103,7 @@ fn draw_authenticating(
102 103 if ui.button("Submit").clicked() && !state.sync_auth_code_input.is_empty() {
103 104 // Manual code entry would need the AuthSession stored somewhere.
104 105 // For now, this is a placeholder — the primary flow uses the callback server.
105 - eprintln!("Manual code entry: {}", state.sync_auth_code_input);
106 + debug!("Manual code entry: {}", state.sync_auth_code_input);
106 107 }
107 108 });
108 109 });
@@ -246,7 +247,7 @@ fn draw_ready(
246 247 let mut sync_files = vfs.sync_files;
247 248 if ui.checkbox(&mut sync_files, &vfs.name).changed() {
248 249 if let Err(e) = state.backend.set_vfs_sync_files(vfs.id, sync_files) {
249 - eprintln!("Failed to update sync_files for VFS {}: {e}", vfs.name);
250 + warn!("Failed to update sync_files for VFS {}: {e}", vfs.name);
250 251 } else {
251 252 state.refresh_vfs_list();
252 253 }
D docs/about.md -107
D docs/todo.md -191