max / audiofiles
20 files changed,
+252 insertions,
-215 deletions
| @@ -271,21 +271,32 @@ | |||
| 271 | 271 | ||
| 272 | 272 | ### Clone | |
| 273 | 273 | ||
| 274 | - | audiofiles depends on the `synckit-client` crate via a relative path. Clone both repositories side by side: | |
| 274 | + | audiofiles depends on the `synckit-client` crate via a relative path, and that path is | |
| 275 | + | `../../../../synckit/synckit-client` (see `crates/audiofiles-sync/Cargo.toml`), so the two | |
| 276 | + | checkouts are not siblings. `synckit-client` is also not its own repository: it lives in the | |
| 277 | + | `synckit` repo alongside `synckit-config`. | |
| 275 | 278 | ||
| 276 | 279 | ``` | |
| 277 | - | git clone https://git.sr.ht/~maxmj/synckit-client | |
| 278 | - | git clone https://git.sr.ht/~maxmj/audiofiles | |
| 280 | + | git clone https://makenot.work/git/max/synckit.git | |
| 281 | + | mkdir -p Apps && git clone https://makenot.work/git/max/audiofiles.git Apps/audiofiles | |
| 279 | 282 | ``` | |
| 280 | 283 | ||
| 281 | 284 | Your directory should look like: | |
| 282 | 285 | ||
| 283 | 286 | ``` | |
| 284 | 287 | parent/ | |
| 285 | - | synckit-client/ | |
| 286 | - | audiofiles/ | |
| 288 | + | synckit/ | |
| 289 | + | synckit-client/ | |
| 290 | + | synckit-config/ | |
| 291 | + | Apps/ | |
| 292 | + | audiofiles/ | |
| 287 | 293 | ``` | |
| 288 | 294 | ||
| 295 | + | That `Apps/` level is load-bearing, and it is load-bearing because the path dependency | |
| 296 | + | encodes the author's own directory layout rather than anything about the projects. Building | |
| 297 | + | from a clone should not require reproducing someone else's filesystem; the fix is a git or | |
| 298 | + | registry dependency, not a deeper set of `../`s. | |
| 299 | + | ||
| 289 | 300 | ### Build the Standalone App | |
| 290 | 301 | ||
| 291 | 302 | ``` |
| @@ -201,7 +201,7 @@ | |||
| 201 | 201 | }); | |
| 202 | 202 | ||
| 203 | 203 | // Floating MIDI/instrument window | |
| 204 | - | if state.show_midi_window { | |
| 204 | + | if state.preview.show_midi_window { | |
| 205 | 205 | instrument_panel::draw_midi_window(&ctx, state); | |
| 206 | 206 | } | |
| 207 | 207 | ||
| @@ -227,7 +227,7 @@ | |||
| 227 | 227 | // Right detail panel (auto-hide below 700px). | |
| 228 | 228 | // 700.0 is the minimum window width at which the detail panel is shown. | |
| 229 | 229 | // Below this, the file list alone needs the full width to remain usable. | |
| 230 | - | if state.detail_visible && ctx.content_rect().width() >= 700.0 { | |
| 230 | + | if state.detail.detail_visible && ctx.content_rect().width() >= 700.0 { | |
| 231 | 231 | egui::Panel::right("detail") | |
| 232 | 232 | .default_size(250.0) | |
| 233 | 233 | .size_range(200.0..=400.0) | |
| @@ -337,7 +337,7 @@ | |||
| 337 | 337 | ||
| 338 | 338 | // Tab: focus the detail-panel tag input (opens the detail panel if hidden). | |
| 339 | 339 | if input.key_pressed(egui::Key::Tab) && !input.modifiers.shift { | |
| 340 | - | if !state.detail_visible { | |
| 340 | + | if !state.detail.detail_visible { | |
| 341 | 341 | state.set_detail_visible(true); | |
| 342 | 342 | } | |
| 343 | 343 | state.focus_tag_input = true; | |
| @@ -413,7 +413,7 @@ | |||
| 413 | 413 | } | |
| 414 | 414 | // "I" toggles floating MIDI/instrument window | |
| 415 | 415 | if input.key_pressed(egui::Key::I) { | |
| 416 | - | state.show_midi_window = !state.show_midi_window; | |
| 416 | + | state.preview.show_midi_window = !state.preview.show_midi_window; | |
| 417 | 417 | } | |
| 418 | 418 | // "E" toggles floating sample editor window | |
| 419 | 419 | if input.key_pressed(egui::Key::E) { |
| @@ -258,7 +258,7 @@ | |||
| 258 | 258 | let mut all = outcome.auto_applied; | |
| 259 | 259 | all.extend(outcome.pending_review); | |
| 260 | 260 | let existing: std::collections::HashSet<&String> = | |
| 261 | - | self.selected_tags.iter().collect(); | |
| 261 | + | self.detail.selected_tags.iter().collect(); | |
| 262 | 262 | all.retain(|s| !existing.contains(&s.tag)); | |
| 263 | 263 | self.status = if all.is_empty() { | |
| 264 | 264 | "No tag suggestions from similar samples.".to_string() | |
| @@ -269,7 +269,7 @@ | |||
| 269 | 269 | if all.len() == 1 { "" } else { "s" } | |
| 270 | 270 | ) | |
| 271 | 271 | }; | |
| 272 | - | self.selected_ml_suggestions = all; | |
| 272 | + | self.detail.selected_ml_suggestions = all; | |
| 273 | 273 | } | |
| 274 | 274 | } | |
| 275 | 275 | } | |
| @@ -475,7 +475,7 @@ | |||
| 475 | 475 | /// Populate `selected_ml_suggestions` with k-NN suggestions for the selected sample. | |
| 476 | 476 | pub fn suggest_ml_for_selected(&mut self) { | |
| 477 | 477 | use audiofiles_core::analysis::exemplar::DEFAULT_K; | |
| 478 | - | self.selected_ml_suggestions.clear(); | |
| 478 | + | self.detail.selected_ml_suggestions.clear(); | |
| 479 | 479 | let Some(hash) = self | |
| 480 | 480 | .selected_node() | |
| 481 | 481 | .and_then(|n| n.node.sample_hash.clone()) | |
| @@ -505,12 +505,12 @@ | |||
| 505 | 505 | return; | |
| 506 | 506 | }; | |
| 507 | 507 | if self.backend.accept_ml_tag(&hash, tag).is_ok() { | |
| 508 | - | self.selected_ml_suggestions.retain(|s| s.tag != tag); | |
| 508 | + | self.detail.selected_ml_suggestions.retain(|s| s.tag != tag); | |
| 509 | 509 | self.status = format!("Added tag \"{tag}\""); | |
| 510 | - | self.selected_tags = | |
| 510 | + | self.detail.selected_tags = | |
| 511 | 511 | std::sync::Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_default()); | |
| 512 | 512 | if let Ok(prov) = self.backend.sample_tag_provenance(&hash) { | |
| 513 | - | self.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect(); | |
| 513 | + | self.detail.selected_tag_sources = prov.into_iter().map(|(t, s, r)| (t, (s, r))).collect(); | |
| 514 | 514 | } | |
| 515 | 515 | } | |
| 516 | 516 | } |
| @@ -186,12 +186,12 @@ | |||
| 186 | 186 | /// overshoot full scale at an integer target are trimmed to the ceiling; | |
| 187 | 187 | /// when off (default) the signal is left untouched and only reported. | |
| 188 | 188 | pub fn toggle_forge_auto_trim_overshoot(&mut self) { | |
| 189 | - | self.forge_auto_trim_overshoot = !self.forge_auto_trim_overshoot; | |
| 189 | + | self.preview.forge_auto_trim_overshoot = !self.preview.forge_auto_trim_overshoot; | |
| 190 | 190 | super::log_backend_err( | |
| 191 | 191 | "set_config forge_auto_trim_overshoot", | |
| 192 | 192 | self.backend.set_config( | |
| 193 | 193 | crate::backend::ConfigKey::ForgeAutoTrimOvershoot, | |
| 194 | - | if self.forge_auto_trim_overshoot { | |
| 194 | + | if self.preview.forge_auto_trim_overshoot { | |
| 195 | 195 | "true" | |
| 196 | 196 | } else { | |
| 197 | 197 | "false" |
| @@ -257,7 +257,7 @@ | |||
| 257 | 257 | pub fn poll_workers(&mut self) -> bool { | |
| 258 | 258 | // Apply any finished off-thread instrument decodes first (independent of | |
| 259 | 259 | // the backend worker channels). | |
| 260 | - | let decodes = self.instrument_decode.drain(); | |
| 260 | + | let decodes = self.preview.instrument_decode.drain(); | |
| 261 | 261 | let decode_activity = !decodes.is_empty(); | |
| 262 | 262 | for outcome in decodes { | |
| 263 | 263 | self.apply_instrument_decode(outcome); |
| @@ -453,7 +453,7 @@ | |||
| 453 | 453 | ||
| 454 | 454 | /// Persist the per-classification dismissed-suggestion map. | |
| 455 | 455 | fn save_dismissed_suggestions(&self) { | |
| 456 | - | let json = match serde_json::to_string(&self.dismissed_suggestions) { | |
| 456 | + | let json = match serde_json::to_string(&self.detail.dismissed_suggestions) { | |
| 457 | 457 | Ok(json) => json, | |
| 458 | 458 | Err(e) => { | |
| 459 | 459 | // HashMap<String, Vec<String>> serialises cleanly, so this is | |
| @@ -473,7 +473,7 @@ | |||
| 473 | 473 | /// stops appearing on every future sample of that class. | |
| 474 | 474 | pub fn dismiss_suggestion(&mut self, classification: &str, tag: &str) { | |
| 475 | 475 | let entry = self | |
| 476 | - | .dismissed_suggestions | |
| 476 | + | .detail.dismissed_suggestions | |
| 477 | 477 | .entry(classification.to_string()) | |
| 478 | 478 | .or_default(); | |
| 479 | 479 | if !entry.iter().any(|t| t == tag) { | |
| @@ -484,7 +484,7 @@ | |||
| 484 | 484 | // last-dismissed marker; older dismissals are no longer reachable | |
| 485 | 485 | // via the inline affordance (they're still in `dismissed_suggestions` | |
| 486 | 486 | // and recoverable via Settings → Reset suggestions). | |
| 487 | - | self.last_dismissed_suggestion = Some(( | |
| 487 | + | self.detail.last_dismissed_suggestion = Some(( | |
| 488 | 488 | classification.to_string(), | |
| 489 | 489 | tag.to_string(), | |
| 490 | 490 | std::time::Instant::now(), | |
| @@ -496,13 +496,13 @@ | |||
| 496 | 496 | /// inline-Undo marker on success or when the entry is gone (already cleared | |
| 497 | 497 | /// by Settings → Reset suggestions, for example). | |
| 498 | 498 | pub fn undo_last_dismissal(&mut self) { | |
| 499 | - | let Some((class, tag, _)) = self.last_dismissed_suggestion.take() else { | |
| 499 | + | let Some((class, tag, _)) = self.detail.last_dismissed_suggestion.take() else { | |
| 500 | 500 | return; | |
| 501 | 501 | }; | |
| 502 | - | if let Some(entry) = self.dismissed_suggestions.get_mut(&class) { | |
| 502 | + | if let Some(entry) = self.detail.dismissed_suggestions.get_mut(&class) { | |
| 503 | 503 | entry.retain(|t| t != &tag); | |
| 504 | 504 | if entry.is_empty() { | |
| 505 | - | self.dismissed_suggestions.remove(&class); | |
| 505 | + | self.detail.dismissed_suggestions.remove(&class); | |
| 506 | 506 | } | |
| 507 | 507 | self.save_dismissed_suggestions(); | |
| 508 | 508 | self.status = format!("Restored suggestion: {tag}"); | |
| @@ -514,11 +514,11 @@ | |||
| 514 | 514 | /// during exploration can start fresh. | |
| 515 | 515 | pub fn reset_dismissed_suggestions(&mut self) { | |
| 516 | 516 | let n: usize = self | |
| 517 | - | .dismissed_suggestions | |
| 517 | + | .detail.dismissed_suggestions | |
| 518 | 518 | .values() | |
| 519 | 519 | .map(std::vec::Vec::len) | |
| 520 | 520 | .sum(); | |
| 521 | - | self.dismissed_suggestions.clear(); | |
| 521 | + | self.detail.dismissed_suggestions.clear(); | |
| 522 | 522 | self.save_dismissed_suggestions(); | |
| 523 | 523 | self.status = format!( | |
| 524 | 524 | "Reset {n} dismissed suggestion{}", |
| @@ -12,7 +12,7 @@ | |||
| 12 | 12 | ||
| 13 | 13 | use audiofiles_core::analysis::AnalysisResult; | |
| 14 | 14 | use audiofiles_core::analysis::config::AnalysisConfig; | |
| 15 | - | use audiofiles_core::analysis::waveform::WaveformData; | |
| 15 | + | ||
| 16 | 16 | use audiofiles_core::db::Database; | |
| 17 | 17 | use audiofiles_core::search::SearchFilter; | |
| 18 | 18 | use audiofiles_core::store::SampleStore; | |
| @@ -147,13 +147,6 @@ | |||
| 147 | 147 | /// Non-blocking native file dialogs (folder/file pickers, save). Results are | |
| 148 | 148 | /// applied on the GUI thread via `draw_browser`'s poll. | |
| 149 | 149 | pub dialogs: crate::ui::dialog::DialogManager, | |
| 150 | - | pub selected_tags: Arc<Vec<String>>, | |
| 151 | - | /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag | |
| 152 | - | /// absent from this map is manual. Refreshed alongside `selected_tags`. | |
| 153 | - | pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>, | |
| 154 | - | /// k-NN tag suggestions for the selected sample, populated on demand by the | |
| 155 | - | /// "Suggest similar tags" action and cleared when the selection changes. | |
| 156 | - | pub selected_ml_suggestions: Vec<audiofiles_core::analysis::exemplar::MlSuggestion>, | |
| 157 | 150 | pub status: String, | |
| 158 | 151 | /// When the current `status` message was posted. Drives the footer's | |
| 159 | 152 | /// time-fade (m-6): fade to muted after 5s, hide after 30s. `None` means | |
| @@ -161,11 +154,8 @@ | |||
| 161 | 154 | /// assignment), the footer treats first-seen-non-empty as freshly-set. | |
| 162 | 155 | pub status_set_at: Option<Instant>, | |
| 163 | 156 | ||
| 164 | - | // Detail panel | |
| 165 | - | pub selected_analysis: Option<AnalysisResult>, | |
| 166 | - | pub selected_waveform: Option<WaveformData>, | |
| 167 | - | pub tag_input: String, | |
| 168 | - | pub detail_visible: bool, | |
| 157 | + | // Detail panel (selected sample analysis/tags, tag entry, dismissal undo) | |
| 158 | + | pub detail: DetailUiState, | |
| 169 | 159 | pub sidebar_visible: bool, | |
| 170 | 160 | ||
| 171 | 161 | // Search / filter / similarity | |
| @@ -174,26 +164,9 @@ | |||
| 174 | 164 | // Tags cache | |
| 175 | 165 | pub all_tags: Arc<Vec<String>>, | |
| 176 | 166 | ||
| 177 | - | // Preview | |
| 178 | - | pub previewing_hash: Option<String>, | |
| 167 | + | // Preview + instrument playback UI | |
| 168 | + | pub preview: PreviewUiState, | |
| 179 | 169 | pub shared: Arc<SharedState>, | |
| 180 | - | pub sample_rate: f32, | |
| 181 | - | pub loop_enabled: bool, | |
| 182 | - | pub autoplay: bool, | |
| 183 | - | /// Forge overshoot policy: when true, a conform that overshoots full scale at | |
| 184 | - | /// an integer target is trimmed to the ceiling; when false (default) the | |
| 185 | - | /// signal is left untouched and the overshoot is only reported. | |
| 186 | - | pub forge_auto_trim_overshoot: bool, | |
| 187 | - | ||
| 188 | - | // Instrument | |
| 189 | - | pub instrument_visible: bool, | |
| 190 | - | pub instrument_root_note: u8, | |
| 191 | - | /// When true, previewing a sample does NOT auto-load it into the instrument. | |
| 192 | - | pub instrument_locked: bool, | |
| 193 | - | /// MIDI notes currently held by piano mouse clicks. | |
| 194 | - | pub piano_held_notes: Vec<u8>, | |
| 195 | - | /// Whether the floating MIDI/instrument window is open. | |
| 196 | - | pub show_midi_window: bool, | |
| 197 | 170 | ||
| 198 | 171 | // MIDI | |
| 199 | 172 | pub midi_state: MidiUiState, | |
| @@ -221,15 +194,6 @@ | |||
| 221 | 194 | /// Set when an inline sidebar editor (collection/tag create or rename) opens, | |
| 222 | 195 | /// so the text field auto-focuses on its first frame (P2 visible-focus gap). | |
| 223 | 196 | pub focus_inline_editor: bool, | |
| 224 | - | /// Per-classification dismissed tag suggestions: e.g. dismissing | |
| 225 | - | /// "percussion" on a kick suppresses it on every future kick. Persisted | |
| 226 | - | /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map. | |
| 227 | - | pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>, | |
| 228 | - | /// Last suggestion that was dismissed plus when. Drives the inline Undo | |
| 229 | - | /// affordance in the detail panel (M-1), visible for ~5 seconds after | |
| 230 | - | /// the dismiss, then fades. `None` means there's nothing to undo right | |
| 231 | - | /// now (initial state or after a successful undo / timeout). | |
| 232 | - | pub last_dismissed_suggestion: Option<(String, String, Instant)>, | |
| 233 | 197 | // Theme | |
| 234 | 198 | pub current_theme_id: String, | |
| 235 | 199 | ||
| @@ -242,14 +206,6 @@ | |||
| 242 | 206 | // Forge, floating sample-forge window (chop / conform / batch) | |
| 243 | 207 | pub forge: ForgeUiState, | |
| 244 | 208 | ||
| 245 | - | /// Off-thread decoder for instrument loading (chromatic auto-load + zone | |
| 246 | - | /// adds). Keeps the full-file decode off the egui frame thread. | |
| 247 | - | pub instrument_decode: crate::preview::InstrumentDecodeWorker, | |
| 248 | - | /// Generation of the most recently dispatched chromatic auto-load. Outcomes | |
| 249 | - | /// with an older generation are dropped so rapid arrow-key previewing can't | |
| 250 | - | /// load a stale instrument over the newest one. | |
| 251 | - | pub latest_chromatic_gen: u64, | |
| 252 | - | ||
| 253 | 209 | // Display density | |
| 254 | 210 | pub row_height: f32, | |
| 255 | 211 | ||
| @@ -464,32 +420,27 @@ | |||
| 464 | 420 | ..Default::default() | |
| 465 | 421 | }, | |
| 466 | 422 | dialogs: crate::ui::dialog::DialogManager::default(), | |
| 467 | - | selected_tags: Arc::new(Vec::new()), | |
| 468 | - | selected_tag_sources: std::collections::HashMap::new(), | |
| 469 | - | selected_ml_suggestions: Vec::new(), | |
| 470 | 423 | status: String::new(), | |
| 471 | 424 | status_set_at: None, | |
| 472 | - | selected_analysis: None, | |
| 473 | - | selected_waveform: None, | |
| 474 | - | tag_input: String::new(), | |
| 475 | - | detail_visible, | |
| 425 | + | detail: DetailUiState { | |
| 426 | + | detail_visible, | |
| 427 | + | dismissed_suggestions, | |
| 428 | + | ..Default::default() | |
| 429 | + | }, | |
| 476 | 430 | sidebar_visible, | |
| 477 | 431 | search: SearchUiState { | |
| 478 | 432 | filter_panel_open, | |
| 479 | 433 | ..Default::default() | |
| 480 | 434 | }, | |
| 481 | 435 | all_tags: Arc::new(all_tags), | |
| 482 | - | previewing_hash: None, | |
| 436 | + | preview: PreviewUiState { | |
| 437 | + | sample_rate, | |
| 438 | + | loop_enabled, | |
| 439 | + | autoplay, | |
| 440 | + | forge_auto_trim_overshoot, | |
| 441 | + | ..Default::default() | |
| 442 | + | }, | |
| 483 | 443 | shared, | |
| 484 | - | sample_rate, | |
| 485 | - | loop_enabled, | |
| 486 | - | autoplay, | |
| 487 | - | forge_auto_trim_overshoot, | |
| 488 | - | instrument_visible: false, | |
| 489 | - | instrument_root_note: 60, | |
| 490 | - | instrument_locked: false, | |
| 491 | - | piano_held_notes: Vec::new(), | |
| 492 | - | show_midi_window: false, | |
| 493 | 444 | midi_state: MidiUiState::default(), | |
| 494 | 445 | midi_pending_action: None, | |
| 495 | 446 | overlay: OverlayUiState::default(), | |
| @@ -505,8 +456,6 @@ | |||
| 505 | 456 | focus_search: false, | |
| 506 | 457 | focus_tag_input: false, | |
| 507 | 458 | focus_inline_editor: false, | |
| 508 | - | dismissed_suggestions, | |
| 509 | - | last_dismissed_suggestion: None, | |
| 510 | 459 | current_theme_id: theme_id, | |
| 511 | 460 | collections_ui: CollectionsUiState { | |
| 512 | 461 | collections: Arc::new(collections_list), | |
| @@ -514,8 +463,6 @@ | |||
| 514 | 463 | }, | |
| 515 | 464 | edit: EditUiState::default(), | |
| 516 | 465 | forge: ForgeUiState::default(), | |
| 517 | - | instrument_decode: crate::preview::InstrumentDecodeWorker::new(), | |
| 518 | - | latest_chromatic_gen: 0, | |
| 519 | 466 | row_height, | |
| 520 | 467 | onboarding: OnboardingUiState { | |
| 521 | 468 | show_vfs_banner: !vfs_explained, |
| @@ -181,20 +181,20 @@ | |||
| 181 | 181 | ||
| 182 | 182 | /// Reload the tag list for the currently focused sample (shown in the detail panel). | |
| 183 | 183 | pub fn refresh_selected_tags(&mut self) { | |
| 184 | - | self.selected_tags = Arc::new(Vec::new()); | |
| 185 | - | self.selected_tag_sources.clear(); | |
| 186 | - | self.selected_ml_suggestions.clear(); | |
| 184 | + | self.detail.selected_tags = Arc::new(Vec::new()); | |
| 185 | + | self.detail.selected_tag_sources.clear(); | |
| 186 | + | self.detail.selected_ml_suggestions.clear(); | |
| 187 | 187 | if let Some(node) = self.selected_node() | |
| 188 | 188 | && let Some(hash) = &node.node.sample_hash | |
| 189 | 189 | { | |
| 190 | 190 | let hash = hash.clone(); | |
| 191 | - | self.selected_tags = | |
| 191 | + | self.detail.selected_tags = | |
| 192 | 192 | Arc::new(self.backend.get_sample_tags(&hash).unwrap_or_else(|e| { | |
| 193 | 193 | warn!("Failed to load tags: {e}"); | |
| 194 | 194 | Vec::new() | |
| 195 | 195 | })); | |
| 196 | 196 | if let Ok(prov) = self.backend.sample_tag_provenance(&hash) { | |
| 197 | - | self.selected_tag_sources = prov | |
| 197 | + | self.detail.selected_tag_sources = prov | |
| 198 | 198 | .into_iter() | |
| 199 | 199 | .map(|(tag, source, rule_id)| (tag, (source, rule_id))) | |
| 200 | 200 | .collect(); | |
| @@ -204,14 +204,14 @@ | |||
| 204 | 204 | ||
| 205 | 205 | /// Refresh the detail panel (analysis + waveform) for the currently selected sample. | |
| 206 | 206 | pub fn refresh_selected_detail(&mut self) { | |
| 207 | - | self.selected_analysis = None; | |
| 208 | - | self.selected_waveform = None; | |
| 207 | + | self.detail.selected_analysis = None; | |
| 208 | + | self.detail.selected_waveform = None; | |
| 209 | 209 | ||
| 210 | 210 | if let Some(node) = self.selected_node() | |
| 211 | 211 | && let Some(hash) = &node.node.sample_hash | |
| 212 | 212 | { | |
| 213 | - | self.selected_analysis = self.backend.get_analysis(hash).unwrap_or(None); | |
| 214 | - | self.selected_waveform = self.backend.get_waveform(hash).unwrap_or(None); | |
| 213 | + | self.detail.selected_analysis = self.backend.get_analysis(hash).unwrap_or(None); | |
| 214 | + | self.detail.selected_waveform = self.backend.get_waveform(hash).unwrap_or(None); | |
| 215 | 215 | } | |
| 216 | 216 | } | |
| 217 | 217 | ||
| @@ -360,14 +360,14 @@ | |||
| 360 | 360 | ||
| 361 | 361 | /// Toggle the right detail panel and persist the choice across restarts. | |
| 362 | 362 | pub fn toggle_detail(&mut self) { | |
| 363 | - | self.set_detail_visible(!self.detail_visible); | |
| 363 | + | self.set_detail_visible(!self.detail.detail_visible); | |
| 364 | 364 | } | |
| 365 | 365 | ||
| 366 | 366 | /// Set detail-panel visibility and persist it (used by both the explicit | |
| 367 | 367 | /// toggle and the auto-show on selection so the stored state never drifts). | |
| 368 | 368 | pub fn set_detail_visible(&mut self, visible: bool) { | |
| 369 | - | if self.detail_visible != visible { | |
| 370 | - | self.detail_visible = visible; | |
| 369 | + | if self.detail.detail_visible != visible { | |
| 370 | + | self.detail.detail_visible = visible; | |
| 371 | 371 | super::log_backend_err( | |
| 372 | 372 | "set_config detail_visible", | |
| 373 | 373 | self.backend.set_config( |
| @@ -33,7 +33,7 @@ | |||
| 33 | 33 | Ok(p) => p, | |
| 34 | 34 | Err(e) => { | |
| 35 | 35 | self.status = e.to_string(); | |
| 36 | - | self.previewing_hash = None; | |
| 36 | + | self.preview.previewing_hash = None; | |
| 37 | 37 | return; | |
| 38 | 38 | } | |
| 39 | 39 | }; | |
| @@ -44,22 +44,22 @@ | |||
| 44 | 44 | .to_string_lossy() | |
| 45 | 45 | .to_string(); | |
| 46 | 46 | // Set the loop flag the streaming thread will honour once it starts. | |
| 47 | - | self.shared.preview.lock().loop_enabled = self.loop_enabled; | |
| 47 | + | self.shared.preview.lock().loop_enabled = self.preview.loop_enabled; | |
| 48 | 48 | match crate::preview::start_streaming_decode(&path, &self.shared) { | |
| 49 | 49 | Ok(()) => { | |
| 50 | - | self.previewing_hash = Some(hash.to_string()); | |
| 50 | + | self.preview.previewing_hash = Some(hash.to_string()); | |
| 51 | 51 | self.status = format!("Playing: {file_name}"); | |
| 52 | 52 | } | |
| 53 | 53 | Err(e) => { | |
| 54 | 54 | self.status = format!("Decode error: {e}"); | |
| 55 | - | self.previewing_hash = None; | |
| 55 | + | self.preview.previewing_hash = None; | |
| 56 | 56 | return; | |
| 57 | 57 | } | |
| 58 | 58 | } | |
| 59 | 59 | ||
| 60 | 60 | // Auto-load previewed sample into the instrument (unless locked). The | |
| 61 | 61 | // decode runs off-thread; the instrument is built when the result arrives. | |
| 62 | - | if !self.instrument_locked { | |
| 62 | + | if !self.preview.instrument_locked { | |
| 63 | 63 | let hash_owned = hash.to_string(); | |
| 64 | 64 | self.load_chromatic_sample(&hash_owned); | |
| 65 | 65 | } | |
| @@ -70,7 +70,7 @@ | |||
| 70 | 70 | let mut playback = self.shared.preview.lock(); | |
| 71 | 71 | playback.playing = false; | |
| 72 | 72 | playback.position_frac = 0.0; | |
| 73 | - | self.previewing_hash = None; | |
| 73 | + | self.preview.previewing_hash = None; | |
| 74 | 74 | self.status.clear(); | |
| 75 | 75 | } | |
| 76 | 76 | ||
| @@ -88,7 +88,7 @@ | |||
| 88 | 88 | ||
| 89 | 89 | /// If autoplay is enabled and the focused node is a sample, preview it. | |
| 90 | 90 | pub fn autoplay_current(&mut self) { | |
| 91 | - | if !self.autoplay { | |
| 91 | + | if !self.preview.autoplay { | |
| 92 | 92 | return; | |
| 93 | 93 | } | |
| 94 | 94 | if let Some(node) = self.selected_node() | |
| @@ -101,26 +101,26 @@ | |||
| 101 | 101 | ||
| 102 | 102 | /// Toggle loop mode and persist the setting. | |
| 103 | 103 | pub fn toggle_loop(&mut self) { | |
| 104 | - | self.loop_enabled = !self.loop_enabled; | |
| 104 | + | self.preview.loop_enabled = !self.preview.loop_enabled; | |
| 105 | 105 | super::log_backend_err( | |
| 106 | 106 | "set_config preview_loop", | |
| 107 | 107 | self.backend.set_config( | |
| 108 | 108 | crate::backend::ConfigKey::PreviewLoop, | |
| 109 | - | if self.loop_enabled { "1" } else { "0" }, | |
| 109 | + | if self.preview.loop_enabled { "1" } else { "0" }, | |
| 110 | 110 | ), | |
| 111 | 111 | ); | |
| 112 | 112 | // Sync to the live playback state | |
| 113 | - | self.shared.preview.lock().loop_enabled = self.loop_enabled; | |
| 113 | + | self.shared.preview.lock().loop_enabled = self.preview.loop_enabled; | |
| 114 | 114 | } | |
| 115 | 115 | ||
| 116 | 116 | /// Toggle autoplay mode and persist the setting. | |
| 117 | 117 | pub fn toggle_autoplay(&mut self) { | |
| 118 | - | self.autoplay = !self.autoplay; | |
| 118 | + | self.preview.autoplay = !self.preview.autoplay; | |
| 119 | 119 | super::log_backend_err( | |
| 120 | 120 | "set_config preview_autoplay", | |
| 121 | 121 | self.backend.set_config( | |
| 122 | 122 | crate::backend::ConfigKey::PreviewAutoplay, | |
| 123 | - | if self.autoplay { "1" } else { "0" }, | |
| 123 | + | if self.preview.autoplay { "1" } else { "0" }, | |
| 124 | 124 | ), | |
| 125 | 125 | ); | |
| 126 | 126 | } | |
| @@ -150,7 +150,7 @@ | |||
| 150 | 150 | .and_then(|k| audiofiles_core::instrument::key_to_root_note(&k)) | |
| 151 | 151 | .unwrap_or(48); | |
| 152 | 152 | ||
| 153 | - | self.latest_chromatic_gen = self.instrument_decode.request( | |
| 153 | + | self.preview.latest_chromatic_gen = self.preview.instrument_decode.request( | |
| 154 | 154 | hash.to_string(), | |
| 155 | 155 | path, | |
| 156 | 156 | crate::preview::DecodePurpose::Chromatic { root_note }, | |
| @@ -161,8 +161,8 @@ | |||
| 161 | 161 | pub fn toggle_instrument(&mut self) { | |
| 162 | 162 | let mut inst = self.shared.instrument.lock(); | |
| 163 | 163 | inst.active = !inst.active; | |
| 164 | - | self.instrument_visible = inst.active; | |
| 165 | - | self.show_midi_window = inst.active; | |
| 164 | + | self.preview.instrument_visible = inst.active; | |
| 165 | + | self.preview.show_midi_window = inst.active; | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | /// Queue a sample to be added as a new zone in multi-sample instrument mode. | |
| @@ -176,7 +176,7 @@ | |||
| 176 | 176 | } | |
| 177 | 177 | }; | |
| 178 | 178 | ||
| 179 | - | self.instrument_decode.request( | |
| 179 | + | self.preview.instrument_decode.request( | |
| 180 | 180 | hash.to_string(), | |
| 181 | 181 | path, | |
| 182 | 182 | crate::preview::DecodePurpose::Zone { | |
| @@ -204,7 +204,7 @@ | |||
| 204 | 204 | match outcome.purpose { | |
| 205 | 205 | DecodePurpose::Chromatic { root_note } => { | |
| 206 | 206 | // Drop superseded auto-loads (rapid arrow-key previewing). | |
| 207 | - | if outcome.generation != self.latest_chromatic_gen { | |
| 207 | + | if outcome.generation != self.preview.latest_chromatic_gen { | |
| 208 | 208 | return; | |
| 209 | 209 | } | |
| 210 | 210 | let zone = crate::instrument::LoadedZone { | |
| @@ -220,7 +220,7 @@ | |||
| 220 | 220 | inst.zone_buffers.clear(); | |
| 221 | 221 | inst.zone_buffers.push(zone); | |
| 222 | 222 | inst.active = true; | |
| 223 | - | inst.sample_rate = self.sample_rate; | |
| 223 | + | inst.sample_rate = self.preview.sample_rate; | |
| 224 | 224 | // Kill all voices | |
| 225 | 225 | for voice in &mut inst.voices { | |
| 226 | 226 | voice.active = false; | |
| @@ -229,7 +229,7 @@ | |||
| 229 | 229 | } | |
| 230 | 230 | drop(inst); | |
| 231 | 231 | ||
| 232 | - | self.instrument_root_note = root_note; | |
| 232 | + | self.preview.instrument_root_note = root_note; | |
| 233 | 233 | } | |
| 234 | 234 | DecodePurpose::Zone { | |
| 235 | 235 | name, | |
| @@ -249,11 +249,11 @@ | |||
| 249 | 249 | inst.config.mode = audiofiles_core::instrument::InstrumentMode::MultiSample; | |
| 250 | 250 | inst.zone_buffers.push(zone); | |
| 251 | 251 | inst.active = true; | |
| 252 | - | inst.sample_rate = self.sample_rate; | |
| 252 | + | inst.sample_rate = self.preview.sample_rate; | |
| 253 | 253 | drop(inst); | |
| 254 | 254 | ||
| 255 | - | self.instrument_visible = true; | |
| 256 | - | self.show_midi_window = true; | |
| 255 | + | self.preview.instrument_visible = true; | |
| 256 | + | self.preview.show_midi_window = true; | |
| 257 | 257 | self.status = format!("Added zone: {name} ({low}-{high})"); | |
| 258 | 258 | } | |
| 259 | 259 | } |
| @@ -1867,17 +1867,17 @@ | |||
| 1867 | 1867 | #[test] | |
| 1868 | 1868 | fn stop_preview_clears_state() { | |
| 1869 | 1869 | let (mut state, _dir) = make_state(); | |
| 1870 | - | state.previewing_hash = Some("abc".to_string()); | |
| 1870 | + | state.preview.previewing_hash = Some("abc".to_string()); | |
| 1871 | 1871 | state.status = "Playing: kick.wav".to_string(); | |
| 1872 | 1872 | state.stop_preview(); | |
| 1873 | - | assert!(state.previewing_hash.is_none()); | |
| 1873 | + | assert!(state.preview.previewing_hash.is_none()); | |
| 1874 | 1874 | assert!(state.status.is_empty()); | |
| 1875 | 1875 | } | |
| 1876 | 1876 | ||
| 1877 | 1877 | #[test] | |
| 1878 | 1878 | fn detail_visible_defaults_true() { | |
| 1879 | 1879 | let (state, _dir) = make_state(); | |
| 1880 | - | assert!(state.detail_visible); | |
| 1880 | + | assert!(state.detail.detail_visible); | |
| 1881 | 1881 | assert!(state.sidebar_visible); | |
| 1882 | 1882 | } | |
| 1883 | 1883 | ||
| @@ -2684,15 +2684,15 @@ | |||
| 2684 | 2684 | { | |
| 2685 | 2685 | let shared = Arc::new(SharedState::new()); | |
| 2686 | 2686 | let mut state = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); | |
| 2687 | - | assert!(!state.forge_auto_trim_overshoot, "default is off"); | |
| 2687 | + | assert!(!state.preview.forge_auto_trim_overshoot, "default is off"); | |
| 2688 | 2688 | state.toggle_forge_auto_trim_overshoot(); | |
| 2689 | - | assert!(state.forge_auto_trim_overshoot); | |
| 2689 | + | assert!(state.preview.forge_auto_trim_overshoot); | |
| 2690 | 2690 | } | |
| 2691 | 2691 | // Reopen the same vault: the toggle is loaded from user-config. | |
| 2692 | 2692 | let shared = Arc::new(SharedState::new()); | |
| 2693 | 2693 | let reloaded = BrowserState::new(dir.path(), shared, 44100.0, "Vault").unwrap(); | |
| 2694 | 2694 | assert!( | |
| 2695 | - | reloaded.forge_auto_trim_overshoot, | |
| 2695 | + | reloaded.preview.forge_auto_trim_overshoot, | |
| 2696 | 2696 | "overshoot toggle should persist across reload" | |
| 2697 | 2697 | ); | |
| 2698 | 2698 | } | |
| @@ -3137,7 +3137,7 @@ | |||
| 3137 | 3137 | ||
| 3138 | 3138 | state.trigger_preview(&hash); | |
| 3139 | 3139 | // Synchronous state set immediately on dispatch. | |
| 3140 | - | assert_eq!(state.previewing_hash.as_deref(), Some(hash.as_str())); | |
| 3140 | + | assert_eq!(state.preview.previewing_hash.as_deref(), Some(hash.as_str())); | |
| 3141 | 3141 | assert!( | |
| 3142 | 3142 | state.status.starts_with("Playing:"), | |
| 3143 | 3143 | "status was: {}", | |
| @@ -3162,7 +3162,7 @@ | |||
| 3162 | 3162 | await_playing(&state); | |
| 3163 | 3163 | ||
| 3164 | 3164 | state.stop_preview(); | |
| 3165 | - | assert!(state.previewing_hash.is_none()); | |
| 3165 | + | assert!(state.preview.previewing_hash.is_none()); | |
| 3166 | 3166 | assert!(state.status.is_empty()); | |
| 3167 | 3167 | let pb = state.shared.preview.lock(); | |
| 3168 | 3168 | assert!(!pb.playing, "stop must halt playback"); | |
| @@ -3187,12 +3187,12 @@ | |||
| 3187 | 3187 | ||
| 3188 | 3188 | // First toggle: not playing -> starts preview. | |
| 3189 | 3189 | state.toggle_preview(); | |
| 3190 | - | assert_eq!(state.previewing_hash.as_deref(), Some(hash.as_str())); | |
| 3190 | + | assert_eq!(state.preview.previewing_hash.as_deref(), Some(hash.as_str())); | |
| 3191 | 3191 | await_playing(&state); | |
| 3192 | 3192 | ||
| 3193 | 3193 | // Second toggle: playing -> stops. | |
| 3194 | 3194 | state.toggle_preview(); | |
| 3195 | - | assert!(state.previewing_hash.is_none()); | |
| 3195 | + | assert!(state.preview.previewing_hash.is_none()); | |
| 3196 | 3196 | assert!(!state.shared.preview.lock().playing); | |
| 3197 | 3197 | } | |
| 3198 | 3198 | ||
| @@ -3204,7 +3204,7 @@ | |||
| 3204 | 3204 | state.refresh_contents(); | |
| 3205 | 3205 | ||
| 3206 | 3206 | state.trigger_preview("deadbeef"); | |
| 3207 | - | assert!(state.previewing_hash.is_none(), "no preview should start"); | |
| 3207 | + | assert!(state.preview.previewing_hash.is_none(), "no preview should start"); | |
| 3208 | 3208 | assert!( | |
| 3209 | 3209 | !state.status.is_empty(), | |
| 3210 | 3210 | "an error status should be surfaced" |
| @@ -417,6 +417,85 @@ | |||
| 417 | 417 | pub scroll_to_row: Option<usize>, | |
| 418 | 418 | } | |
| 419 | 419 | ||
| 420 | + | /// Preview + instrument playback UI state: the previewing sample, playback | |
| 421 | + | /// preferences (loop / autoplay / forge overshoot trim), and the instrument | |
| 422 | + | /// panel's keyboard/MIDI window and off-thread chromatic loader. | |
| 423 | + | pub struct PreviewUiState { | |
| 424 | + | /// Content hash of the sample currently loaded into the preview stream. | |
| 425 | + | pub previewing_hash: Option<String>, | |
| 426 | + | /// GUI-side copy of the device output sample rate, used to seed instrument | |
| 427 | + | /// playback. Set from the real device rate at construction. | |
| 428 | + | pub sample_rate: f32, | |
| 429 | + | pub loop_enabled: bool, | |
| 430 | + | pub autoplay: bool, | |
| 431 | + | /// Forge overshoot policy: when true, a conform that overshoots full scale at | |
| 432 | + | /// an integer target is trimmed to the ceiling; when false (default) the | |
| 433 | + | /// signal is left untouched and the overshoot is only reported. | |
| 434 | + | pub forge_auto_trim_overshoot: bool, | |
| 435 | + | ||
| 436 | + | pub instrument_visible: bool, | |
| 437 | + | pub instrument_root_note: u8, | |
| 438 | + | /// When true, previewing a sample does NOT auto-load it into the instrument. | |
| 439 | + | pub instrument_locked: bool, | |
| 440 | + | /// MIDI notes currently held by piano mouse clicks. | |
| 441 | + | pub piano_held_notes: Vec<u8>, | |
| 442 | + | /// Whether the floating MIDI/instrument window is open. | |
| 443 | + | pub show_midi_window: bool, | |
| 444 | + | /// Off-thread decoder for instrument loading (chromatic auto-load + zone | |
| 445 | + | /// adds). Keeps the full-file decode off the egui frame thread. | |
| 446 | + | pub instrument_decode: crate::preview::InstrumentDecodeWorker, | |
| 447 | + | /// Generation of the most recently dispatched chromatic auto-load. Outcomes | |
| 448 | + | /// with an older generation are dropped so rapid arrow-key previewing can't | |
| 449 | + | /// load a stale instrument over the newest one. | |
| 450 | + | pub latest_chromatic_gen: u64, | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | impl Default for PreviewUiState { | |
| 454 | + | fn default() -> Self { | |
| 455 | + | Self { | |
| 456 | + | previewing_hash: None, | |
| 457 | + | sample_rate: 44100.0, | |
| 458 | + | loop_enabled: false, | |
| 459 | + | autoplay: false, | |
| 460 | + | forge_auto_trim_overshoot: false, | |
| 461 | + | instrument_visible: false, | |
| 462 | + | instrument_root_note: 60, | |
| 463 | + | instrument_locked: false, | |
| 464 | + | piano_held_notes: Vec::new(), | |
| 465 | + | show_midi_window: false, | |
| 466 | + | instrument_decode: crate::preview::InstrumentDecodeWorker::new(), | |
| 467 | + | latest_chromatic_gen: 0, | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | /// Detail panel state: the selected sample's analysis/waveform, its tags and | |
| 473 | + | /// tag provenance, the tag-entry buffer, the panel-visible toggle, and the | |
| 474 | + | /// dismissed-suggestion machinery that drives the inline Undo affordance. | |
| 475 | + | #[derive(Default)] | |
| 476 | + | pub struct DetailUiState { | |
| 477 | + | pub selected_analysis: Option<audiofiles_core::analysis::AnalysisResult>, | |
| 478 | + | pub selected_waveform: Option<audiofiles_core::analysis::waveform::WaveformData>, | |
| 479 | + | pub selected_tags: std::sync::Arc<Vec<String>>, | |
| 480 | + | /// Per-tag provenance for the selected sample: tag -> (source, rule_id). A tag | |
| 481 | + | /// absent from this map is manual. Refreshed alongside `selected_tags`. | |
| 482 | + | pub selected_tag_sources: std::collections::HashMap<String, (String, Option<String>)>, | |
| 483 | + | /// k-NN tag suggestions for the selected sample, populated on demand by the | |
| 484 | + | /// "Suggest similar tags" action and cleared when the selection changes. | |
| 485 | + | pub selected_ml_suggestions: Vec<audiofiles_core::analysis::exemplar::MlSuggestion>, | |
| 486 | + | pub tag_input: String, | |
| 487 | + | pub detail_visible: bool, | |
| 488 | + | /// Per-classification dismissed tag suggestions: e.g. dismissing | |
| 489 | + | /// "percussion" on a kick suppresses it on every future kick. Persisted | |
| 490 | + | /// under config key "suggestions.dismissed" as a JSON `<class>` → `[tag]` map. | |
| 491 | + | pub dismissed_suggestions: std::collections::HashMap<String, Vec<String>>, | |
| 492 | + | /// Last suggestion that was dismissed plus when. Drives the inline Undo | |
| 493 | + | /// affordance in the detail panel (M-1), visible for ~5 seconds after | |
| 494 | + | /// the dismiss, then fades. `None` means there's nothing to undo right | |
| 495 | + | /// now (initial state or after a successful undo / timeout). | |
| 496 | + | pub last_dismissed_suggestion: Option<(String, String, Instant)>, | |
| 497 | + | } | |
| 498 | + | ||
| 420 | 499 | /// Import / analysis workflow state: the active screen, retry snapshots, | |
| 421 | 500 | /// error accumulation, and pending review/save buffers. | |
| 422 | 501 | #[derive(Default)] |
| @@ -20,10 +20,10 @@ | |||
| 20 | 20 | }; | |
| 21 | 21 | ||
| 22 | 22 | // Waveform | |
| 23 | - | if let Some(ref waveform_data) = state.selected_waveform { | |
| 23 | + | if let Some(ref waveform_data) = state.detail.selected_waveform { | |
| 24 | 24 | // Compute playback position as a 0.0–1.0 fraction for the waveform cursor. | |
| 25 | 25 | // Only valid when the currently-playing hash matches this node's hash. | |
| 26 | - | let playback_pos = if state.previewing_hash.as_deref() == node.node.sample_hash.as_deref() { | |
| 26 | + | let playback_pos = if state.preview.previewing_hash.as_deref() == node.node.sample_hash.as_deref() { | |
| 27 | 27 | let playback = state.shared.preview.lock(); | |
| 28 | 28 | if playback.playing { | |
| 29 | 29 | if let Some(ref buf) = playback.buffer { | |
| @@ -90,7 +90,7 @@ | |||
| 90 | 90 | let rect = resp.rect; | |
| 91 | 91 | let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); | |
| 92 | 92 | if let Some(hash) = &node.node.sample_hash | |
| 93 | - | && state.previewing_hash.as_deref() == Some(hash) | |
| 93 | + | && state.preview.previewing_hash.as_deref() == Some(hash) | |
| 94 | 94 | { | |
| 95 | 95 | let mut playback = state.shared.preview.lock(); | |
| 96 | 96 | if let Some(ref buf) = playback.buffer { | |
| @@ -115,7 +115,7 @@ | |||
| 115 | 115 | ui.add_space(theme::space::MD); | |
| 116 | 116 | ||
| 117 | 117 | // Analysis metadata grid | |
| 118 | - | if let Some(ref analysis) = state.selected_analysis { | |
| 118 | + | if let Some(ref analysis) = state.detail.selected_analysis { | |
| 119 | 119 | egui::CollapsingHeader::new("Metadata") | |
| 120 | 120 | .id_salt("detail_metadata_section") | |
| 121 | 121 | .default_open(true) | |
| @@ -191,11 +191,11 @@ | |||
| 191 | 191 | .id_salt("detail_tags_section") | |
| 192 | 192 | .default_open(true) | |
| 193 | 193 | .show(ui, |ui| { | |
| 194 | - | if state.selected_tags.is_empty() { | |
| 194 | + | if state.detail.selected_tags.is_empty() { | |
| 195 | 195 | ui.label(egui::RichText::new("No tags").color(theme::content_muted())); | |
| 196 | 196 | } else { | |
| 197 | 197 | ui.horizontal_wrapped(|ui| { | |
| 198 | - | let tags = state.selected_tags.clone(); | |
| 198 | + | let tags = state.detail.selected_tags.clone(); | |
| 199 | 199 | for tag in tags.iter() { | |
| 200 | 200 | if widgets::tag_chip_removable(ui, tag, true) { | |
| 201 | 201 | // Remove tag and push an undoable entry so Cmd+Z restores it. | |
| @@ -218,7 +218,7 @@ | |||
| 218 | 218 | // Tag input | |
| 219 | 219 | ui.horizontal(|ui| { | |
| 220 | 220 | let resp = ui.add( | |
| 221 | - | egui::TextEdit::singleline(&mut state.tag_input) | |
| 221 | + | egui::TextEdit::singleline(&mut state.detail.tag_input) | |
| 222 | 222 | .hint_text("Add tag (use dots: genre.house)") | |
| 223 | 223 | .desired_width(ui.available_width() - 40.0), | |
| 224 | 224 | ); | |
| @@ -230,13 +230,13 @@ | |||
| 230 | 230 | if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) | |
| 231 | 231 | || ui.small_button("+").on_hover_text("Add tag").clicked() | |
| 232 | 232 | { | |
| 233 | - | let tag = state.tag_input.trim().to_string(); | |
| 233 | + | let tag = state.detail.tag_input.trim().to_string(); | |
| 234 | 234 | if !tag.is_empty() | |
| 235 | 235 | && let Some(ref hash) = node.node.sample_hash | |
| 236 | 236 | { | |
| 237 | 237 | if audiofiles_core::tags::validate_tag(&tag).is_ok() { | |
| 238 | 238 | let _ = state.backend.add_tag(hash, &tag); | |
| 239 | - | state.tag_input.clear(); | |
| 239 | + | state.detail.tag_input.clear(); | |
| 240 | 240 | state.refresh_selected_tags(); | |
| 241 | 241 | } else { | |
| 242 | 242 | state.status = format!("Invalid tag: {tag}"); | |
| @@ -247,14 +247,14 @@ | |||
| 247 | 247 | ||
| 248 | 248 | // Tag provenance: where each tag came from (manual vs rule/ml/cluster/folder), | |
| 249 | 249 | // so "why does this sample have this tag?" is answerable at a glance. | |
| 250 | - | if !state.selected_tags.is_empty() { | |
| 250 | + | if !state.detail.selected_tags.is_empty() { | |
| 251 | 251 | egui::CollapsingHeader::new("Tag sources") | |
| 252 | 252 | .id_salt("detail_tag_sources") | |
| 253 | 253 | .default_open(false) | |
| 254 | 254 | .show(ui, |ui| { | |
| 255 | - | let tags = state.selected_tags.clone(); | |
| 255 | + | let tags = state.detail.selected_tags.clone(); | |
| 256 | 256 | for tag in tags.iter() { | |
| 257 | - | let (label, color) = match state.selected_tag_sources.get(tag) { | |
| 257 | + | let (label, color) = match state.detail.selected_tag_sources.get(tag) { | |
| 258 | 258 | None => ("manual", theme::content_muted()), | |
| 259 | 259 | Some((source, _)) => match source.as_str() { | |
| 260 | 260 | "rule" => ("rule", theme::action()), | |
| @@ -287,8 +287,8 @@ | |||
| 287 | 287 | { | |
| 288 | 288 | state.suggest_ml_for_selected(); | |
| 289 | 289 | } | |
| 290 | - | if !state.selected_ml_suggestions.is_empty() { | |
| 291 | - | let suggestions = state.selected_ml_suggestions.clone(); | |
| 290 | + | if !state.detail.selected_ml_suggestions.is_empty() { | |
| 291 | + | let suggestions = state.detail.selected_ml_suggestions.clone(); | |
| 292 | 292 | let mut accept: Option<String> = None; | |
| 293 | 293 | for s in &suggestions { | |
| 294 | 294 | ui.horizontal(|ui| { | |
| @@ -315,17 +315,17 @@ | |||
| 315 | 315 | // Tag suggestions based on classification. Per-classification dismissals | |
| 316 | 316 | // let the user say "I never tag kicks with `percussion`" once and have | |
| 317 | 317 | // the suggestion stop appearing on every future kick. | |
| 318 | - | if let Some(ref analysis) = state.selected_analysis | |
| 318 | + | if let Some(ref analysis) = state.detail.selected_analysis | |
| 319 | 319 | && let Some(ref class) = analysis.classification | |
| 320 | 320 | { | |
| 321 | 321 | let class_str = class.to_string(); | |
| 322 | 322 | let dismissed_for_class = state | |
| 323 | - | .dismissed_suggestions | |
| 323 | + | .detail.dismissed_suggestions | |
| 324 | 324 | .get(&class_str) | |
| 325 | 325 | .cloned() | |
| 326 | 326 | .unwrap_or_default(); | |
| 327 | 327 | let suggestions: Vec<&'static str> = | |
| 328 | - | classification_tag_suggestions(&class_str, &state.selected_tags) | |
| 328 | + | classification_tag_suggestions(&class_str, &state.detail.selected_tags) | |
| 329 | 329 | .into_iter() | |
| 330 | 330 | .filter(|s| !dismissed_for_class.iter().any(|d| d == s)) | |
| 331 | 331 | .collect(); | |
| @@ -389,7 +389,7 @@ | |||
| 389 | 389 | const UNDO_WINDOW: f32 = 5.0; | |
| 390 | 390 | // Bind the tag directly so there's no re-fetch + expect() in render. | |
| 391 | 391 | let undo_tag = state | |
| 392 | - | .last_dismissed_suggestion | |
| 392 | + | .detail.last_dismissed_suggestion | |
| 393 | 393 | .as_ref() | |
| 394 | 394 | .filter(|(c, _, at)| { | |
| 395 | 395 | c == &class_str && at.elapsed().as_secs_f32() < UNDO_WINDOW | |
| @@ -460,11 +460,11 @@ | |||
| 460 | 460 | // these the button "works" but always returns zero results, | |
| 461 | 461 | // which reads as a broken feature instead of a missing prereq. | |
| 462 | 462 | let has_spectral = state | |
| 463 | - | .selected_analysis | |
| 463 | + | .detail.selected_analysis | |
| 464 | 464 | .as_ref() | |
| 465 | 465 | .is_some_and(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some()); | |
| 466 | 466 | let has_fingerprint = state | |
| 467 | - | .selected_analysis | |
| 467 | + | .detail.selected_analysis | |
| 468 | 468 | .as_ref() | |
| 469 | 469 | .is_some_and(|a| a.fingerprint.is_some()); | |
| 470 | 470 | egui::CollapsingHeader::new("Discovery") |
| @@ -71,11 +71,11 @@ | |||
| 71 | 71 | ||
| 72 | 72 | /// Waveform display with playback cursor and click-to-seek. | |
| 73 | 73 | fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) { | |
| 74 | - | if let Some(ref waveform_data) = state.selected_waveform { | |
| 74 | + | if let Some(ref waveform_data) = state.detail.selected_waveform { | |
| 75 | 75 | // Show the playhead whenever this sample is the active preview and a | |
| 76 | 76 | // buffer is loaded, including while paused, so the user can see where | |
| 77 | 77 | // playback sits before auditioning a trim. | |
| 78 | - | let playback_pos = if state.previewing_hash.as_deref() == Some(hash) { | |
| 78 | + | let playback_pos = if state.preview.previewing_hash.as_deref() == Some(hash) { | |
| 79 | 79 | let playback = state.shared.preview.lock(); | |
| 80 | 80 | playback.buffer.as_ref().and_then(|buf| { | |
| 81 | 81 | let total_frames = if playback.streaming { | |
| @@ -154,7 +154,7 @@ | |||
| 154 | 154 | { | |
| 155 | 155 | let rect = resp.rect; | |
| 156 | 156 | let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); | |
| 157 | - | if state.previewing_hash.as_deref() != Some(hash) { | |
| 157 | + | if state.preview.previewing_hash.as_deref() != Some(hash) { | |
| 158 | 158 | state.trigger_preview(hash); | |
| 159 | 159 | } | |
| 160 | 160 | let mut playback = state.shared.preview.lock(); | |
| @@ -177,7 +177,7 @@ | |||
| 177 | 177 | /// the main file-list selection so the user can audition before committing a | |
| 178 | 178 | /// destructive edit. | |
| 179 | 179 | fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) { | |
| 180 | - | let is_current = state.previewing_hash.as_deref() == Some(hash); | |
| 180 | + | let is_current = state.preview.previewing_hash.as_deref() == Some(hash); | |
| 181 | 181 | let playing = is_current && state.shared.preview.lock().playing; | |
| 182 | 182 | ui.horizontal(|ui| { | |
| 183 | 183 | if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() { | |
| @@ -227,7 +227,7 @@ | |||
| 227 | 227 | ||
| 228 | 228 | /// Info line: name, sample rate, duration, peak dB. | |
| 229 | 229 | fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) { | |
| 230 | - | if let Some(ref analysis) = state.selected_analysis { | |
| 230 | + | if let Some(ref analysis) = state.detail.selected_analysis { | |
| 231 | 231 | let name = state | |
| 232 | 232 | .selected_node() | |
| 233 | 233 | .map(|n| n.node.name.clone()) | |
| @@ -260,7 +260,7 @@ | |||
| 260 | 260 | ui.label(egui::RichText::new("Trim").strong()); | |
| 261 | 261 | ||
| 262 | 262 | let sample_rate = state | |
| 263 | - | .selected_analysis | |
| 263 | + | .detail.selected_analysis | |
| 264 | 264 | .as_ref() | |
| 265 | 265 | .map_or(44100, |a| a.sample_rate); | |
| 266 | 266 | let total = state.edit.total_frames; | |
| @@ -307,7 +307,7 @@ | |||
| 307 | 307 | ui.label(egui::RichText::new("Levels").strong()); | |
| 308 | 308 | ||
| 309 | 309 | // Current peak display + gain clipping warning | |
| 310 | - | let current_peak = state.selected_analysis.as_ref().and_then(|a| a.peak_db); | |
| 310 | + | let current_peak = state.detail.selected_analysis.as_ref().and_then(|a| a.peak_db); | |
| 311 | 311 | if let Some(peak) = current_peak { | |
| 312 | 312 | let predicted = peak + state.edit.gain_db; | |
| 313 | 313 | if predicted > 0.0 { | |
| @@ -464,7 +464,7 @@ | |||
| 464 | 464 | // range only if duration is missing (un-analyzed sample). Prevents the | |
| 465 | 465 | // silent-failure / undefined-behaviour case where positions exceed length. | |
| 466 | 466 | let duration_ms_cap = state | |
| 467 | - | .selected_analysis | |
| 467 | + | .detail.selected_analysis | |
| 468 | 468 | .as_ref() | |
| 469 | 469 | .map_or(f64::MAX, |a| a.duration * 1000.0); | |
| 470 | 470 |
| @@ -447,7 +447,7 @@ | |||
| 447 | 447 | } | |
| 448 | 448 | } | |
| 449 | 449 | } else { | |
| 450 | - | let is_playing = state.previewing_hash.as_deref() == Some(hash) | |
| 450 | + | let is_playing = state.preview.previewing_hash.as_deref() == Some(hash) | |
| 451 | 451 | && state.shared.preview.lock().playing; | |
| 452 | 452 | let btn_text = if is_playing { "Stop" } else { "Play" }; | |
| 453 | 453 | let hover = if is_playing { | |
| @@ -608,7 +608,7 @@ | |||
| 608 | 608 | } | |
| 609 | 609 | ||
| 610 | 610 | // Drag source for instrument zone assignment (not for cloud-only) | |
| 611 | - | if state.instrument_visible | |
| 611 | + | if state.preview.instrument_visible | |
| 612 | 612 | && node.node.node_type == NodeType::Sample | |
| 613 | 613 | && !node.cloud_only | |
| 614 | 614 | && let Some(hash) = &node.node.sample_hash | |
| @@ -622,7 +622,7 @@ | |||
| 622 | 622 | // Native OS drag-out to Finder/DAW (only when instrument panel is closed) | |
| 623 | 623 | #[cfg(any(target_os = "macos", target_os = "windows"))] | |
| 624 | 624 | if !os_drag_blocked | |
| 625 | - | && !state.instrument_visible | |
| 625 | + | && !state.preview.instrument_visible | |
| 626 | 626 | && node.node.node_type == NodeType::Sample | |
| 627 | 627 | && !node.cloud_only | |
| 628 | 628 | && resp.dragged() |