Skip to main content

max / audiofiles

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