//! Shared browser state: thread-safe browser state, import workflow, and analysis coordination. //! //! [`SharedState`] bridges the cpal audio output thread and the GUI thread via lock-free access. //! [`BrowserState`] holds the full GUI-side model: VFS navigation, preview, and analysis workflow. use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::{AtomicU32, AtomicU64}; use std::time::Instant; use tracing::{error, warn}; use audiofiles_core::analysis::config::AnalysisConfig; use audiofiles_core::analysis::waveform::WaveformData; use audiofiles_core::analysis::AnalysisResult; use audiofiles_core::db::Database; use audiofiles_core::error::CoreError; use audiofiles_core::collections::Collection; use audiofiles_core::search::SearchFilter; use audiofiles_core::store::SampleStore; use audiofiles_core::util::split_name_ext; use audiofiles_core::vfs::{NodeType, Vfs, VfsNode}; use audiofiles_core::{CollectionId, NodeId, VfsId}; pub use audiofiles_core::vfs::VfsNodeWithAnalysis; use parking_lot::Mutex; use crate::backend::{Backend, DirectBackend, ImportStrategyDesc}; use crate::import::{ImportedFolder, ImportStrategy}; use crate::instrument::InstrumentPlayback; use crate::preview::PreviewPlayback; mod navigation; pub mod import_workflow; mod bulk_ops; mod forge; mod library; mod playback; mod ui; #[cfg(test)] mod tests; // Re-export all UI types so they remain accessible at `crate::state::*` pub use ui::*; /// Shared between cpal audio output thread and GUI thread. /// Audio thread uses try_lock -- never blocks. pub struct SharedState { /// Preview playback buffer and position, accessed from GUI and cpal audio threads. pub preview: Mutex, /// Instrument playback state (voice pool, loaded zones), accessed from GUI thread. pub instrument: Mutex, /// Actual device output sample rate (set once at startup). pub device_sample_rate: AtomicU32, /// MIDI note events pushed by the MIDI callback, drained by the GUI each frame. pub midi_recent_notes: Mutex>, /// Generation counter for streaming decode threads. Each new decode increments /// the generation; the thread exits if its generation no longer matches. pub decode_generation: AtomicU64, /// Name of the cpal output device currently bound to the preview stream. /// Written once at startup from `audio::start_output_stream` and read by /// the footer to surface "Preview: " for diagnostic visibility. /// `None` means no device is available (audio output failed to start). pub preview_device_name: Mutex>, } impl Default for SharedState { fn default() -> Self { Self { preview: Mutex::new(PreviewPlayback::new()), instrument: Mutex::new(InstrumentPlayback::new(8)), device_sample_rate: AtomicU32::new(44100), midi_recent_notes: Mutex::new(Vec::new()), decode_generation: AtomicU64::new(0), preview_device_name: Mutex::new(None), } } } impl SharedState { /// Create a new `SharedState` with an empty, stopped preview. pub fn new() -> Self { Self::default() } } /// GUI-thread-only state, passed as egui user_state T. pub struct BrowserState { pub data_dir: PathBuf, pub backend: Box, // Navigation pub vfs_list: Arc>, pub current_vfs_idx: usize, pub current_dir: Option, pub breadcrumb: Vec, pub contents: Arc>, pub selection: Selection, pub selected_tags: Arc>, pub status: String, /// When the current `status` message was posted. Drives the footer's /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means /// the status was set without going through `post_status` (legacy direct /// assignment) — the footer treats first-seen-non-empty as freshly-set. pub status_set_at: Option, // Detail panel pub selected_analysis: Option, pub selected_waveform: Option, pub tag_input: String, pub detail_visible: bool, pub sidebar_visible: bool, // Sort pub sort_column: SortColumn, pub sort_direction: SortDirection, // Search / filter pub search_query: String, pub search_filter: SearchFilter, pub filter_panel_open: bool, // Dynamic collection (saved search) name input pub collection_filter_name_input: String, /// Free-form input bound to the filter panel's Tags section so users can /// add tag filters from inside the filter panel itself (M-5 closed the /// add/remove asymmetry — tag chips already had a remove X, but no entry). pub filter_tag_input: String, // Similarity search pub similarity_search_hash: Option, /// Display name of the source sample for the active similarity / duplicate /// search. Cached so the breadcrumb can render "Similar to: " without /// a backend lookup on every frame. pub similarity_source_name: Option, // Tags cache pub all_tags: Arc>, /// Sidebar tag tree filter input. pub tag_search: String, // Preview pub previewing_hash: Option, pub shared: Arc, pub sample_rate: f32, pub loop_enabled: bool, pub autoplay: bool, /// Forge overshoot policy: when true, a conform that overshoots full scale at /// an integer target is trimmed to the ceiling; when false (default) the /// signal is left untouched and the overshoot is only reported. pub forge_auto_trim_overshoot: bool, // Instrument pub instrument_visible: bool, pub instrument_root_note: u8, /// When true, previewing a sample does NOT auto-load it into the instrument. pub instrument_locked: bool, /// MIDI notes currently held by piano mouse clicks. pub piano_held_notes: Vec, /// Whether the floating MIDI/instrument window is open. pub show_midi_window: bool, // MIDI pub midi_state: MidiUiState, /// Set by the UI, consumed by the app layer each frame. pub midi_pending_action: Option, // Overlays pub show_help: bool, /// Help overlay tab: 0 = Shortcuts, 1 = Features. pub help_tab: u8, /// Set by the toolbar's Help menu when the user picks "About". The app /// layer polls this each frame and flips its own `show_about`. Lives in /// browser state (not app state) because the browser owns the toolbar. pub about_requested: bool, pub pending_confirm: Option, // VFS management modals pub vfs_create_input: String, pub vfs_rename_target: Option<(VfsId, String)>, pub dir_create_input: String, pub show_vfs_create: bool, pub show_dir_create: bool, pub dir_rename_target: Option<(NodeId, String)>, // Bulk operations pub undo_stack: Vec, pub bulk_modal: Option, pub column_config: ColumnConfig, // Analysis pub import_mode: ImportMode, /// When true, the import flow skips ConfigureImport, TagFolders, and ConfigureAnalysis. pub quick_import: bool, /// Pending Quick-Import awaiting user confirmation. Set when the picked /// folder exceeds the preflight thresholds in `import_workflow.rs`. pub pending_import_preflight: Option, // M-9: persistent dismissal of the import preflight modal; loaded from // config in new() so the user's prior choice survives restart. pub import_preflight_disabled: bool, // M-9: transient checkbox state for the "Don't ask again" affordance. // Reset to false on every modal close path. pub preflight_dont_ask: bool, // M-2: search input on the Shortcuts help tab; filters the grid live. pub help_shortcut_search: String, // M-6: search input on the Bulk Move modal; filters the directory list. pub bulk_move_filter: String, pub pending_review_items: Vec, // Error accumulation for import/analysis workflows pub import_file_errors: Vec, pub analysis_errors: Vec, pub import_errors_expanded: bool, // Retry state: last import source path so the user can restart from the config screen. pub last_import_source: Option, // Retry state: last analysis parameters so the user can restart analysis. pub last_analysis_hashes: Vec<(String, String)>, pub last_analysis_config: Option, /// Destination of the in-flight export. Stashed when `run_export` spawns /// so the cancel-acknowledgement screen (C-3) can surface "files already /// written to remain". pub last_export_destination: Option, /// Stashed folder-tag entries from the most recent TagFolders pass so the /// Back button on the ConfigureAnalysis screen can rehydrate the previous /// state (C-1). Tags themselves are `INSERT OR IGNORE` so re-applying after /// a Back is a no-op for the backend. #[allow(clippy::type_complexity)] // a snapshot tuple for Back-button rehydration; a named alias would not earn its keep pub last_folder_tags: Option<(Vec, Vec<(String, String)>)>, /// Rolling progress samples for the current long-running operation /// (import / analysis / export). Drives the rate + ETA readout (M-11). /// Reset when an operation starts; consulted by the corresponding draw fn. pub operation_progress: Option, /// Tag input on the Tag Folders screen's "Apply to all" row (M-9). /// Persists across frames so the user can type the value, then click the /// commit button. Reset when leaving the screen via Back / Skip / Apply. pub tag_folders_apply_all_input: String, /// Last backend error from a name-modal submit (vault create/rename, folder /// create/rename). Surfaces inline so the modal can stay open on failure /// rather than discarding the user's typed input (C-3). Cleared on modal /// open / successful submit / explicit Cancel. pub name_modal_error: Option, /// Set by "/" keyboard shortcut to focus the search bar on the next frame. pub focus_search: bool, /// Set by Tab from the file table to focus the detail-panel tag input on the next frame. pub focus_tag_input: bool, /// Set when an inline sidebar editor (collection/tag create or rename) opens, /// so the text field auto-focuses on its first frame (P2 visible-focus gap). pub focus_inline_editor: bool, /// Per-classification dismissed tag suggestions: e.g. dismissing /// "percussion" on a kick suppresses it on every future kick. Persisted /// under config key "suggestions.dismissed" as a JSON `` → `[tag]` map. pub dismissed_suggestions: std::collections::HashMap>, /// Last suggestion that was dismissed plus when. Drives the inline Undo /// affordance in the detail panel (M-1) — visible for ~5 seconds after /// the dismiss, then fades. `None` means there's nothing to undo right /// now (initial state or after a successful undo / timeout). pub last_dismissed_suggestion: Option<(String, String, Instant)>, /// Set by keyboard navigation to scroll the file list to the focused row. pub scroll_to_row: Option, // Theme pub current_theme_id: String, // Collections pub collections: Vec, pub active_collection: Option, pub collection_create_input: String, pub collection_rename_target: Option<(CollectionId, String)>, /// Inline rename for a tag in the sidebar: `(old_tag, new_name_buffer)`. /// Submission calls `backend.rename_tag_globally` and refreshes the tag list. pub tag_rename_target: Option<(String, String)>, /// Cached preview for the active rename: `(affected_sample_count, descendant_tags)`. /// Computed when `tag_rename_target` is opened (M-12); cleared when modal closes. /// Descendants are listed so the user knows they will NOT be renamed (the /// backend's `rename_tag_globally` is exact-match-only). pub tag_rename_preview: Option<(usize, Vec)>, pub show_collection_create: bool, // Edit — floating editor window pub edit: EditUiState, // Forge — floating sample-forge window (chop / conform / batch) pub forge: ForgeUiState, // Display density pub row_height: f32, // First-run onboarding pub show_vfs_banner: bool, /// Show "Right-click for options · F1 for shortcuts" hint until dismissed. pub show_first_launch_hint: bool, /// Show the "set up cloud sync to back up your library" banner. Surfaces /// once after the first successful import; persisted via `sync_intro_dismissed`. pub show_sync_intro: bool, // Drag-out /// Set when an OS drag fires; prevents re-triggering until the pointer is /// genuinely released (egui sees button-up) or a safety timeout expires. pub os_drag_cooldown: Option, // VFS mirror pub mirror_enabled: bool, pub mirror_path: PathBuf, pub mirror_dirty: bool, // Sync pub sync: SyncUiState, // Settings (consolidated window) pub settings: SettingsUiState, // Loose-files mode integrity /// Number of loose-files mode samples with missing source files (0 = healthy or not loose-files). pub loose_files_missing_count: usize, /// Whether to show the integrity warning overlay. pub show_loose_files_warning: bool, } impl BrowserState { /// Initialise the browser: open (or create) the database and sample store, /// create a default "Library" VFS if none exist, and load the root listing. pub fn new( data_dir: &Path, shared: Arc, sample_rate: f32, vault_name: &str, ) -> Result> { std::fs::create_dir_all(data_dir)?; let db_path = data_dir.join("audiofiles.db"); let db = Database::open(&db_path)?; let store_dir = data_dir.join("samples"); let store = SampleStore::new(&store_dir)?; let backend = Box::new(DirectBackend::new(db, store, data_dir.to_path_buf())); Self::new_with_backend(backend, data_dir, shared, sample_rate, vault_name) } /// Initialise the browser with an externally-provided backend. /// /// The backend handles all database and store operations. This constructor /// is used by `new()` (with DirectBackend). pub fn new_with_backend( backend: Box, data_dir: &Path, shared: Arc, sample_rate: f32, vault_name: &str, ) -> Result> { let mut vfs_list = backend.list_vfs()?; if vfs_list.is_empty() { backend.create_vfs("Vault")?; vfs_list = backend.list_vfs()?; } // Restore the last-selected vault by id (indices shift as vaults are // added/removed), so the initial contents below load for the right vault. let current_vfs_idx = backend .get_config("current_vfs_id") .ok() .flatten() .and_then(|s| s.parse::().ok()) .and_then(|id| vfs_list.iter().position(|v| v.id.as_i64() == id)) .unwrap_or(0); let contents = backend.list_children_enriched(vfs_list[current_vfs_idx].id, None) .unwrap_or_else(|e| { error!("Failed to load initial contents: {e}"); Vec::new() }); let all_tags = backend.list_all_tags() .unwrap_or_else(|e| { warn!("Failed to load tags: {e}"); Vec::new() }); let collections_list = backend.list_collections() .unwrap_or_else(|e| { warn!("Failed to load collections: {e}"); Vec::new() }); // Load saved theme preference let theme_id = backend.get_config("theme") .ok() .flatten() .unwrap_or_else(|| "audiofiles".to_string()); crate::ui::theme::init(Some(&theme_id)); // Load preview settings let loop_enabled = backend.get_config("preview_loop").ok().flatten().as_deref() == Some("1"); let autoplay = backend.get_config("preview_autoplay").ok().flatten().as_deref() == Some("1"); let forge_auto_trim_overshoot = backend .get_config(crate::backend::FORGE_AUTO_TRIM_OVERSHOOT_KEY) .ok() .flatten() .as_deref() == Some("true"); // First-run VFS banner let vfs_explained = backend.get_config("vfs_explained").ok().flatten().as_deref() == Some("1"); let hints_dismissed = backend.get_config("hints_dismissed").ok().flatten().as_deref() == Some("1"); let sync_intro_dismissed = backend.get_config("sync_intro_dismissed").ok().flatten().as_deref() == Some("1"); // M-9: load persistent preflight dismissal. let import_preflight_disabled = backend .get_config("import_preflight_disabled") .ok() .flatten() .as_deref() == Some("1"); // Load display density let row_height = backend.get_config("row_height").ok().flatten() .and_then(|s| s.parse::().ok()) .unwrap_or(24.0) .clamp(20.0, 32.0); // Load dismissed tag suggestions let dismissed_suggestions: std::collections::HashMap> = backend .get_config("suggestions.dismissed") .ok() .flatten() .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); // Load mirror settings let mirror_enabled = backend.get_config("mirror_enabled").ok().flatten().as_deref() == Some("1"); let mirror_path = backend .get_config("mirror_path") .ok() .flatten() .map(PathBuf::from) .unwrap_or_else(|| { dirs::home_dir() .unwrap_or_else(|| data_dir.to_path_buf()) .join("audiofiles") }); // Restore shell layout state (persisted on toggle). Sidebar/detail // default to shown, filter panel to closed. let sidebar_visible = backend.get_config("sidebar_visible").ok().flatten().as_deref() != Some("0"); let detail_visible = backend.get_config("detail_visible").ok().flatten().as_deref() != Some("0"); let filter_panel_open = backend.get_config("filter_panel_open").ok().flatten().as_deref() == Some("1"); Ok(Self { data_dir: data_dir.to_path_buf(), backend, vfs_list: Arc::new(vfs_list), current_vfs_idx, current_dir: None, breadcrumb: Vec::new(), contents: Arc::new(contents), selection: Selection::new(), selected_tags: Arc::new(Vec::new()), status: String::new(), status_set_at: None, selected_analysis: None, selected_waveform: None, tag_input: String::new(), detail_visible, sidebar_visible, sort_column: SortColumn::Name, sort_direction: SortDirection::Ascending, search_query: String::new(), search_filter: SearchFilter::default(), filter_panel_open, collection_filter_name_input: String::new(), filter_tag_input: String::new(), similarity_search_hash: None, similarity_source_name: None, all_tags: Arc::new(all_tags), tag_search: String::new(), previewing_hash: None, shared, sample_rate, loop_enabled, autoplay, forge_auto_trim_overshoot, instrument_visible: false, instrument_root_note: 60, instrument_locked: false, piano_held_notes: Vec::new(), show_midi_window: false, midi_state: MidiUiState::default(), midi_pending_action: None, show_help: false, help_tab: 0, about_requested: false, pending_confirm: None, vfs_create_input: String::new(), vfs_rename_target: None, dir_create_input: String::new(), show_vfs_create: false, show_dir_create: false, dir_rename_target: None, undo_stack: Vec::new(), bulk_modal: None, column_config: ColumnConfig::default(), import_mode: ImportMode::None, quick_import: false, pending_import_preflight: None, // M-9. import_preflight_disabled, preflight_dont_ask: false, // M-2. help_shortcut_search: String::new(), // M-6. bulk_move_filter: String::new(), pending_review_items: Vec::new(), import_file_errors: Vec::new(), analysis_errors: Vec::new(), import_errors_expanded: false, last_import_source: None, last_analysis_hashes: Vec::new(), last_analysis_config: None, last_export_destination: None, last_folder_tags: None, operation_progress: None, tag_folders_apply_all_input: String::new(), name_modal_error: None, focus_search: false, focus_tag_input: false, focus_inline_editor: false, dismissed_suggestions, last_dismissed_suggestion: None, scroll_to_row: None, current_theme_id: theme_id, collections: collections_list, active_collection: None, collection_create_input: String::new(), collection_rename_target: None, tag_rename_target: None, tag_rename_preview: None, show_collection_create: false, edit: EditUiState::default(), forge: ForgeUiState::default(), row_height, show_vfs_banner: !vfs_explained, show_first_launch_hint: !hints_dismissed, // Suppressed until the first import completes (see import_workflow.rs). show_sync_intro: !sync_intro_dismissed, os_drag_cooldown: None, mirror_enabled, mirror_path, mirror_dirty: mirror_enabled, sync: SyncUiState::default(), settings: SettingsUiState { name: vault_name.to_string(), ..Default::default() }, loose_files_missing_count: 0, show_loose_files_warning: false, }) } /// Post a transient status message to the footer. Stamps `status_set_at` /// so the footer's time-fade (m-6) restarts. Prefer this over direct /// `self.status = ...` assignment so new posts reliably reset the fade. pub fn post_status(&mut self, msg: impl Into) { self.status = msg.into(); self.status_set_at = Some(Instant::now()); } }