Skip to main content

max / audiofiles

20.6 KB · 514 lines History Blame Raw
1 //! Shared browser state: thread-safe browser state, import workflow, and analysis coordination.
2 //!
3 //! [`SharedState`] bridges the cpal audio output thread and the GUI thread via lock-free access.
4 //! [`BrowserState`] holds the full GUI-side model: VFS navigation, preview, and analysis workflow.
5
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::sync::Arc;
9 use std::sync::atomic::{AtomicU32, AtomicU64};
10 use std::time::Instant;
11
12 use tracing::{error, warn};
13
14 use audiofiles_core::analysis::config::AnalysisConfig;
15 use audiofiles_core::analysis::waveform::WaveformData;
16 use audiofiles_core::analysis::AnalysisResult;
17 use audiofiles_core::db::Database;
18 use audiofiles_core::error::CoreError;
19 use audiofiles_core::collections::Collection;
20 use audiofiles_core::search::SearchFilter;
21 use audiofiles_core::store::SampleStore;
22 use audiofiles_core::util::split_name_ext;
23 use audiofiles_core::vfs::{NodeType, Vfs, VfsNode};
24 use audiofiles_core::{CollectionId, NodeId, VfsId};
25 pub use audiofiles_core::vfs::VfsNodeWithAnalysis;
26 use parking_lot::Mutex;
27
28 use crate::backend::{Backend, DirectBackend, ImportStrategyDesc};
29
30 use crate::import::{ImportedFolder, ImportStrategy};
31 use crate::instrument::InstrumentPlayback;
32 use crate::preview::PreviewPlayback;
33
34 mod navigation;
35 pub mod import_workflow;
36 mod bulk_ops;
37 mod library;
38 mod playback;
39 mod ui;
40
41 #[cfg(test)]
42 mod tests;
43
44 // Re-export all UI types so they remain accessible at `crate::state::*`
45 pub use ui::*;
46
47 /// Shared between cpal audio output thread and GUI thread.
48 /// Audio thread uses try_lock -- never blocks.
49 pub struct SharedState {
50 /// Preview playback buffer and position, accessed from GUI and cpal audio threads.
51 pub preview: Mutex<PreviewPlayback>,
52 /// Instrument playback state (voice pool, loaded zones), accessed from GUI thread.
53 pub instrument: Mutex<InstrumentPlayback>,
54 /// Actual device output sample rate (set once at startup).
55 pub device_sample_rate: AtomicU32,
56 /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame.
57 pub midi_recent_notes: Mutex<Vec<MidiNoteEvent>>,
58 /// Generation counter for streaming decode threads. Each new decode increments
59 /// the generation; the thread exits if its generation no longer matches.
60 pub decode_generation: AtomicU64,
61 /// Name of the cpal output device currently bound to the preview stream.
62 /// Written once at startup from `audio::start_output_stream` and read by
63 /// the footer to surface "Preview: <device>" for diagnostic visibility.
64 /// `None` means no device is available (audio output failed to start).
65 pub preview_device_name: Mutex<Option<String>>,
66 }
67
68 impl Default for SharedState {
69 fn default() -> Self {
70 Self {
71 preview: Mutex::new(PreviewPlayback::new()),
72 instrument: Mutex::new(InstrumentPlayback::new(8)),
73 device_sample_rate: AtomicU32::new(44100),
74 midi_recent_notes: Mutex::new(Vec::new()),
75 decode_generation: AtomicU64::new(0),
76 preview_device_name: Mutex::new(None),
77 }
78 }
79 }
80
81 impl SharedState {
82 /// Create a new `SharedState` with an empty, stopped preview.
83 pub fn new() -> Self {
84 Self::default()
85 }
86 }
87
88 /// GUI-thread-only state, passed as egui user_state T.
89 pub struct BrowserState {
90 pub data_dir: PathBuf,
91 pub backend: Box<dyn Backend>,
92
93 // Navigation
94 pub vfs_list: Arc<Vec<Vfs>>,
95 pub current_vfs_idx: usize,
96 pub current_dir: Option<NodeId>,
97 pub breadcrumb: Vec<VfsNode>,
98 pub contents: Arc<Vec<VfsNodeWithAnalysis>>,
99 pub selection: Selection,
100 pub selected_tags: Arc<Vec<String>>,
101 pub status: String,
102 /// When the current `status` message was posted. Drives the footer's
103 /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means
104 /// the status was set without going through `post_status` (legacy direct
105 /// assignment) — the footer treats first-seen-non-empty as freshly-set.
106 pub status_set_at: Option<Instant>,
107
108 // Detail panel
109 pub selected_analysis: Option<AnalysisResult>,
110 pub selected_waveform: Option<WaveformData>,
111 pub tag_input: String,
112 pub detail_visible: bool,
113 pub sidebar_visible: bool,
114
115 // Sort
116 pub sort_column: SortColumn,
117 pub sort_direction: SortDirection,
118
119 // Search / filter
120 pub search_query: String,
121 pub search_filter: SearchFilter,
122 pub filter_panel_open: bool,
123
124 // Dynamic collection (saved search) name input
125 pub collection_filter_name_input: String,
126
127 /// Free-form input bound to the filter panel's Tags section so users can
128 /// add tag filters from inside the filter panel itself (M-5 closed the
129 /// add/remove asymmetry — tag chips already had a remove X, but no entry).
130 pub filter_tag_input: String,
131
132 // Similarity search
133 pub similarity_search_hash: Option<String>,
134 /// Display name of the source sample for the active similarity / duplicate
135 /// search. Cached so the breadcrumb can render "Similar to: <name>" without
136 /// a backend lookup on every frame.
137 pub similarity_source_name: Option<String>,
138
139 // Tags cache
140 pub all_tags: Arc<Vec<String>>,
141 /// Sidebar tag tree filter input.
142 pub tag_search: String,
143
144 // Preview
145 pub previewing_hash: Option<String>,
146 pub shared: Arc<SharedState>,
147 pub sample_rate: f32,
148 pub loop_enabled: bool,
149 pub autoplay: bool,
150
151 // Instrument
152 pub instrument_visible: bool,
153 pub instrument_root_note: u8,
154 /// When true, previewing a sample does NOT auto-load it into the instrument.
155 pub instrument_locked: bool,
156 /// MIDI notes currently held by piano mouse clicks.
157 pub piano_held_notes: Vec<u8>,
158 /// Whether the floating MIDI/instrument window is open.
159 pub show_midi_window: bool,
160
161 // MIDI
162 pub midi_state: MidiUiState,
163 /// Set by the UI, consumed by the app layer each frame.
164 pub midi_pending_action: Option<MidiAction>,
165
166 // Overlays
167 pub show_help: bool,
168 /// Help overlay tab: 0 = Shortcuts, 1 = Features.
169 pub help_tab: u8,
170 pub pending_confirm: Option<ConfirmAction>,
171
172 // VFS management modals
173 pub vfs_create_input: String,
174 pub vfs_rename_target: Option<(VfsId, String)>,
175 pub dir_create_input: String,
176 pub show_vfs_create: bool,
177 pub show_dir_create: bool,
178 pub dir_rename_target: Option<(NodeId, String)>,
179
180 // Bulk operations
181 pub undo_stack: Vec<UndoOp>,
182 pub bulk_modal: Option<BulkModal>,
183 pub column_config: ColumnConfig,
184
185 // Analysis
186 pub import_mode: ImportMode,
187 /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis.
188 pub quick_import: bool,
189 /// Pending Quick-Import awaiting user confirmation. Set when the picked
190 /// folder exceeds the preflight thresholds in `import_workflow.rs`.
191 pub pending_import_preflight: Option<crate::state::import_workflow::ImportPreflight>,
192 // M-9: persistent dismissal of the import preflight modal; loaded from
193 // config in new() so the user's prior choice survives restart.
194 pub import_preflight_disabled: bool,
195 // M-9: transient checkbox state for the "Don't ask again" affordance.
196 // Reset to false on every modal close path.
197 pub preflight_dont_ask: bool,
198 // M-2: search input on the Shortcuts help tab; filters the grid live.
199 pub help_shortcut_search: String,
200 // M-6: search input on the Bulk Move modal; filters the directory list.
201 pub bulk_move_filter: String,
202 pub pending_review_items: Vec<ReviewItem>,
203
204 // Error accumulation for import/analysis workflows
205 pub import_file_errors: Vec<ImportFileError>,
206 pub analysis_errors: Vec<AnalysisFileError>,
207 pub import_errors_expanded: bool,
208
209 // Retry state: last import source path so the user can restart from the config screen.
210 pub last_import_source: Option<PathBuf>,
211 // Retry state: last analysis parameters so the user can restart analysis.
212 pub last_analysis_hashes: Vec<(String, String)>,
213 pub last_analysis_config: Option<AnalysisConfig>,
214 /// Destination of the in-flight export. Stashed when `run_export` spawns
215 /// so the cancel-acknowledgement screen (C-3) can surface "files already
216 /// written to <destination> remain".
217 pub last_export_destination: Option<PathBuf>,
218 /// Stashed folder-tag entries from the most recent TagFolders pass so the
219 /// Back button on the ConfigureAnalysis screen can rehydrate the previous
220 /// state (C-1). Tags themselves are `INSERT OR IGNORE` so re-applying after
221 /// a Back is a no-op for the backend.
222 pub last_folder_tags: Option<(Vec<crate::state::FolderTagEntry>, Vec<(String, String)>)>,
223 /// Rolling progress samples for the current long-running operation
224 /// (import / analysis / export). Drives the rate + ETA readout (M-11).
225 /// Reset when an operation starts; consulted by the corresponding draw fn.
226 pub operation_progress: Option<crate::state::OperationProgress>,
227 /// Tag input on the Tag Folders screen's "Apply to all" row (M-9).
228 /// Persists across frames so the user can type the value, then click the
229 /// commit button. Reset when leaving the screen via Back / Skip / Apply.
230 pub tag_folders_apply_all_input: String,
231 /// Last backend error from a name-modal submit (vault create/rename, folder
232 /// create/rename). Surfaces inline so the modal can stay open on failure
233 /// rather than discarding the user's typed input (C-3). Cleared on modal
234 /// open / successful submit / explicit Cancel.
235 pub name_modal_error: Option<String>,
236 /// Set by "/" keyboard shortcut to focus the search bar on the next frame.
237 pub focus_search: bool,
238 /// Set by Tab from the file table to focus the detail-panel tag input on the next frame.
239 pub focus_tag_input: bool,
240 /// Per-classification dismissed tag suggestions: e.g. dismissing
241 /// "percussion" on a kick suppresses it on every future kick. Persisted
242 /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map.
243 pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>,
244 /// Last suggestion that was dismissed plus when. Drives the inline Undo
245 /// affordance in the detail panel (M-1) — visible for ~5 seconds after
246 /// the dismiss, then fades. `None` means there's nothing to undo right
247 /// now (initial state or after a successful undo / timeout).
248 pub last_dismissed_suggestion: Option<(String, String, Instant)>,
249 /// Set by keyboard navigation to scroll the file list to the focused row.
250 pub scroll_to_row: Option<usize>,
251
252 // Theme
253 pub current_theme_id: String,
254
255 // Collections
256 pub collections: Vec<Collection>,
257 pub active_collection: Option<CollectionId>,
258 pub collection_create_input: String,
259 pub collection_rename_target: Option<(CollectionId, String)>,
260 /// Inline rename for a tag in the sidebar: `(old_tag, new_name_buffer)`.
261 /// Submission calls `backend.rename_tag_globally` and refreshes the tag list.
262 pub tag_rename_target: Option<(String, String)>,
263 /// Cached preview for the active rename: `(affected_sample_count, descendant_tags)`.
264 /// Computed when `tag_rename_target` is opened (M-12); cleared when modal closes.
265 /// Descendants are listed so the user knows they will NOT be renamed (the
266 /// backend's `rename_tag_globally` is exact-match-only).
267 pub tag_rename_preview: Option<(usize, Vec<String>)>,
268 pub show_collection_create: bool,
269
270 // Edit — floating editor window
271 pub edit: EditUiState,
272
273 // Display density
274 pub row_height: f32,
275
276 // First-run onboarding
277 pub show_vfs_banner: bool,
278 /// Show "Right-click for options · F1 for shortcuts" hint until dismissed.
279 pub show_first_launch_hint: bool,
280 /// Show the "set up cloud sync to back up your library" banner. Surfaces
281 /// once after the first successful import; persisted via `sync_intro_dismissed`.
282 pub show_sync_intro: bool,
283
284 // Drag-out
285 /// Set when an OS drag fires; prevents re-triggering until the pointer is
286 /// genuinely released (egui sees button-up) or a safety timeout expires.
287 pub os_drag_cooldown: Option<Instant>,
288
289 // VFS mirror
290 pub mirror_enabled: bool,
291 pub mirror_path: PathBuf,
292 pub mirror_dirty: bool,
293
294 // Sync
295 pub sync: SyncUiState,
296
297 // Settings (consolidated window)
298 pub settings: SettingsUiState,
299
300 // Loose-files mode integrity
301 /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files).
302 pub loose_files_missing_count: usize,
303 /// Whether to show the integrity warning overlay.
304 pub show_loose_files_warning: bool,
305 }
306
307 impl BrowserState {
308 /// Initialise the browser: open (or create) the database and sample store,
309 /// create a default "Library" VFS if none exist, and load the root listing.
310 pub fn new(
311 data_dir: &Path,
312 shared: Arc<SharedState>,
313 sample_rate: f32,
314 vault_name: &str,
315 ) -> Result<Self, Box<dyn std::error::Error>> {
316 std::fs::create_dir_all(data_dir)?;
317
318 let db_path = data_dir.join("audiofiles.db");
319 let db = Database::open(&db_path)?;
320
321 let store_dir = data_dir.join("samples");
322 let store = SampleStore::new(&store_dir)?;
323
324 let backend = Box::new(DirectBackend::new(db, store, data_dir.to_path_buf()));
325 Self::new_with_backend(backend, data_dir, shared, sample_rate, vault_name)
326 }
327
328 /// Initialise the browser with an externally-provided backend.
329 ///
330 /// The backend handles all database and store operations. This constructor
331 /// is used by `new()` (with DirectBackend).
332 pub fn new_with_backend(
333 backend: Box<dyn Backend>,
334 data_dir: &Path,
335 shared: Arc<SharedState>,
336 sample_rate: f32,
337 vault_name: &str,
338 ) -> Result<Self, Box<dyn std::error::Error>> {
339 let mut vfs_list = backend.list_vfs()?;
340 if vfs_list.is_empty() {
341 backend.create_vfs("Vault")?;
342 vfs_list = backend.list_vfs()?;
343 }
344
345 let contents = backend.list_children_enriched(vfs_list[0].id, None)
346 .unwrap_or_else(|e| { error!("Failed to load initial contents: {e}"); Vec::new() });
347 let all_tags = backend.list_all_tags()
348 .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() });
349 let collections_list = backend.list_collections()
350 .unwrap_or_else(|e| { warn!("Failed to load collections: {e}"); Vec::new() });
351
352 // Load saved theme preference
353 let theme_id = backend.get_config("theme")
354 .ok()
355 .flatten()
356 .unwrap_or_else(|| "audiofiles".to_string());
357 crate::ui::theme::init(Some(&theme_id));
358
359 // Load preview settings
360 let loop_enabled = backend.get_config("preview_loop").ok().flatten().as_deref() == Some("1");
361 let autoplay = backend.get_config("preview_autoplay").ok().flatten().as_deref() == Some("1");
362
363 // First-run VFS banner
364 let vfs_explained = backend.get_config("vfs_explained").ok().flatten().as_deref() == Some("1");
365 let hints_dismissed = backend.get_config("hints_dismissed").ok().flatten().as_deref() == Some("1");
366 let sync_intro_dismissed = backend.get_config("sync_intro_dismissed").ok().flatten().as_deref() == Some("1");
367 // M-9: load persistent preflight dismissal.
368 let import_preflight_disabled = backend
369 .get_config("import_preflight_disabled")
370 .ok()
371 .flatten()
372 .as_deref()
373 == Some("1");
374
375 // Load display density
376 let row_height = backend.get_config("row_height").ok().flatten()
377 .and_then(|s| s.parse::<f32>().ok())
378 .unwrap_or(24.0)
379 .clamp(20.0, 32.0);
380
381 // Load dismissed tag suggestions
382 let dismissed_suggestions: std::collections::HashMap<String, Vec<String>> = backend
383 .get_config("suggestions.dismissed")
384 .ok()
385 .flatten()
386 .and_then(|s| serde_json::from_str(&s).ok())
387 .unwrap_or_default();
388
389 // Load mirror settings
390 let mirror_enabled = backend.get_config("mirror_enabled").ok().flatten().as_deref() == Some("1");
391 let mirror_path = backend
392 .get_config("mirror_path")
393 .ok()
394 .flatten()
395 .map(PathBuf::from)
396 .unwrap_or_else(|| {
397 dirs::home_dir()
398 .unwrap_or_else(|| data_dir.to_path_buf())
399 .join("audiofiles")
400 });
401
402 Ok(Self {
403 data_dir: data_dir.to_path_buf(),
404 backend,
405 vfs_list: Arc::new(vfs_list),
406 current_vfs_idx: 0,
407 current_dir: None,
408 breadcrumb: Vec::new(),
409 contents: Arc::new(contents),
410 selection: Selection::new(),
411 selected_tags: Arc::new(Vec::new()),
412 status: String::new(),
413 status_set_at: None,
414 selected_analysis: None,
415 selected_waveform: None,
416 tag_input: String::new(),
417 detail_visible: true,
418 sidebar_visible: true,
419 sort_column: SortColumn::Name,
420 sort_direction: SortDirection::Ascending,
421 search_query: String::new(),
422 search_filter: SearchFilter::default(),
423 filter_panel_open: false,
424 collection_filter_name_input: String::new(),
425 filter_tag_input: String::new(),
426 similarity_search_hash: None,
427 similarity_source_name: None,
428 all_tags: Arc::new(all_tags),
429 tag_search: String::new(),
430 previewing_hash: None,
431 shared,
432 sample_rate,
433 loop_enabled,
434 autoplay,
435 instrument_visible: false,
436 instrument_root_note: 60,
437 instrument_locked: false,
438 piano_held_notes: Vec::new(),
439 show_midi_window: false,
440 midi_state: MidiUiState::default(),
441 midi_pending_action: None,
442 show_help: false,
443 help_tab: 0,
444 pending_confirm: None,
445 vfs_create_input: String::new(),
446 vfs_rename_target: None,
447 dir_create_input: String::new(),
448 show_vfs_create: false,
449 show_dir_create: false,
450 dir_rename_target: None,
451 undo_stack: Vec::new(),
452 bulk_modal: None,
453 column_config: ColumnConfig::default(),
454 import_mode: ImportMode::None,
455 quick_import: false,
456 pending_import_preflight: None,
457 // M-9.
458 import_preflight_disabled,
459 preflight_dont_ask: false,
460 // M-2.
461 help_shortcut_search: String::new(),
462 // M-6.
463 bulk_move_filter: String::new(),
464 pending_review_items: Vec::new(),
465 import_file_errors: Vec::new(),
466 analysis_errors: Vec::new(),
467 import_errors_expanded: false,
468 last_import_source: None,
469 last_analysis_hashes: Vec::new(),
470 last_analysis_config: None,
471 last_export_destination: None,
472 last_folder_tags: None,
473 operation_progress: None,
474 tag_folders_apply_all_input: String::new(),
475 name_modal_error: None,
476 focus_search: false,
477 focus_tag_input: false,
478 dismissed_suggestions,
479 last_dismissed_suggestion: None,
480 scroll_to_row: None,
481 current_theme_id: theme_id,
482 collections: collections_list,
483 active_collection: None,
484 collection_create_input: String::new(),
485 collection_rename_target: None,
486 tag_rename_target: None,
487 tag_rename_preview: None,
488 show_collection_create: false,
489 edit: EditUiState::default(),
490 row_height,
491 show_vfs_banner: !vfs_explained,
492 show_first_launch_hint: !hints_dismissed,
493 // Suppressed until the first import completes (see import_workflow.rs).
494 show_sync_intro: !sync_intro_dismissed,
495 os_drag_cooldown: None,
496 mirror_enabled,
497 mirror_path,
498 mirror_dirty: mirror_enabled,
499 sync: SyncUiState::default(),
500 settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() },
501 loose_files_missing_count: 0,
502 show_loose_files_warning: false,
503 })
504 }
505
506 /// Post a transient status message to the footer. Stamps `status_set_at`
507 /// so the footer's time-fade (m-6) restarts. Prefer this over direct
508 /// `self.status = ...` assignment so new posts reliably reset the fade.
509 pub fn post_status(&mut self, msg: impl Into<String>) {
510 self.status = msg.into();
511 self.status_set_at = Some(Instant::now());
512 }
513 }
514