Skip to main content

max / audiofiles

27.6 KB · 661 lines History Blame Raw
1 //! Right detail panel: waveform display, metadata grid, tags, and copy-path button.
2
3 use egui;
4
5 use crate::state::BrowserState;
6 use crate::waveform;
7 use super::theme;
8 use super::widgets;
9
10 /// Draw the detail panel content for the currently selected sample.
11 pub fn draw_detail(ui: &mut egui::Ui, state: &mut BrowserState) {
12 if state.selection.count() > 1 {
13 draw_multi_summary(ui, state);
14 return;
15 }
16
17 let node = match state.selected_node() {
18 Some(n) => n,
19 None => {
20 widgets::empty_state(ui, "Select a sample", None, None);
21 return;
22 }
23 };
24
25 // Waveform
26 if let Some(ref waveform_data) = state.selected_waveform {
27 // Compute playback position as a 0.0–1.0 fraction for the waveform cursor.
28 // Only valid when the currently-playing hash matches this node's hash.
29 let playback_pos = if state.previewing_hash.as_deref() == node.node.sample_hash.as_deref() {
30 let playback = state.shared.preview.lock();
31 if playback.playing {
32 if let Some(ref buf) = playback.buffer {
33 // During streaming, the buffer grows so use the metadata estimate
34 // for a stable cursor. Fall back to current buffer size otherwise.
35 let total_frames = if playback.streaming {
36 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
37 } else {
38 buf.data.len() / 2
39 };
40 if total_frames > 0 {
41 Some((playback.position_frac / total_frames as f64) as f32)
42 } else {
43 None
44 }
45 } else {
46 None
47 }
48 } else {
49 None
50 }
51 } else {
52 None
53 };
54
55 let resp = waveform::draw_waveform(ui, waveform_data, playback_pos, 120.0);
56 // Hover indicator: paint a vertical accent_blue line at the cursor X
57 // and a time label above it so the user can see where a click-to-seek
58 // would land before committing.
59 if resp.hovered() {
60 if let Some(pos) = resp.hover_pos() {
61 let rect = resp.rect;
62 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
63 let total_secs = waveform_data.duration as f32;
64 let cursor_secs = normalized * total_secs;
65 ui.painter().line_segment(
66 [
67 egui::pos2(pos.x, rect.top()),
68 egui::pos2(pos.x, rect.bottom()),
69 ],
70 egui::Stroke::new(1.0, theme::accent_blue()),
71 );
72 let label = format!(
73 "{:.0}:{:02.0}",
74 (cursor_secs / 60.0).floor(),
75 cursor_secs % 60.0,
76 );
77 ui.painter().text(
78 egui::pos2(pos.x, rect.top() - 2.0),
79 egui::Align2::CENTER_BOTTOM,
80 label,
81 egui::FontId::proportional(10.0),
82 theme::text_secondary(),
83 );
84 }
85 }
86 // Click-to-seek: map the click's X position to a 0.0–1.0 fraction
87 // within the waveform rect, then set the playback cursor to that frame.
88 if resp.clicked() {
89 if let Some(pos) = resp.interact_pointer_pos() {
90 let rect = resp.rect;
91 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
92 if let Some(hash) = &node.node.sample_hash {
93 if state.previewing_hash.as_deref() == Some(hash) {
94 let mut playback = state.shared.preview.lock();
95 if let Some(ref buf) = playback.buffer {
96 let total_frames = if playback.streaming {
97 playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
98 } else {
99 buf.data.len() / 2
100 };
101 playback.position_frac = (normalized as f64 * total_frames as f64)
102 .min((playback.decoded_frames.max(1) - 1) as f64);
103 }
104 }
105 }
106 }
107 }
108
109 ui.add_space(theme::section_spacing());
110 }
111
112 // Sample name
113 ui.label(egui::RichText::new(&node.node.name).strong().size(14.0));
114 ui.add_space(theme::space::MD);
115
116 // Analysis metadata grid
117 if let Some(ref analysis) = state.selected_analysis {
118 egui::CollapsingHeader::new("Metadata")
119 .id_salt("detail_metadata_section")
120 .default_open(true)
121 .show(ui, |ui| {
122 egui::Grid::new("detail_metadata")
123 .num_columns(2)
124 .spacing([8.0, theme::grid_row_spacing()])
125 .show(ui, |ui| {
126 ui.label(egui::RichText::new("Duration").color(theme::text_secondary()));
127 ui.label(widgets::format_duration(analysis.duration));
128 ui.end_row();
129
130 if let Some(bpm) = analysis.bpm {
131 ui.label(egui::RichText::new("BPM").color(theme::text_secondary()));
132 ui.label(widgets::format_bpm(bpm));
133 ui.end_row();
134 }
135
136 if let Some(ref key) = analysis.musical_key {
137 ui.label(egui::RichText::new("Key").color(theme::text_secondary()));
138 ui.label(key);
139 ui.end_row();
140 }
141
142 if let Some(ref class) = analysis.classification {
143 ui.label(egui::RichText::new("Class").color(theme::text_secondary()));
144 widgets::classification_badge(ui, class.as_str());
145 ui.end_row();
146 }
147
148 ui.label(egui::RichText::new("Sample Rate").color(theme::text_secondary()));
149 ui.label(format!("{} Hz", analysis.sample_rate));
150 ui.end_row();
151
152 ui.label(egui::RichText::new("Channels").color(theme::text_secondary()));
153 ui.label(format!("{}", analysis.channels));
154 ui.end_row();
155
156 if let Some(peak) = analysis.peak_db {
157 ui.label(egui::RichText::new("Peak").color(theme::text_secondary()));
158 ui.label(format!("{:.1} dB", peak));
159 ui.end_row();
160 }
161
162 if let Some(rms) = analysis.rms_db {
163 ui.label(egui::RichText::new("RMS").color(theme::text_secondary()));
164 ui.label(format!("{:.1} dB", rms));
165 ui.end_row();
166 }
167
168 if let Some(lufs) = analysis.lufs {
169 ui.label(egui::RichText::new("LUFS").color(theme::text_secondary()));
170 ui.label(format!("{:.1}", lufs));
171 ui.end_row();
172 }
173
174 if let Some(is_loop) = analysis.is_loop {
175 ui.label(egui::RichText::new("Loop").color(theme::text_secondary()));
176 ui.label(if is_loop { "Yes" } else { "No" });
177 ui.end_row();
178 }
179 });
180 });
181 }
182
183 ui.add_space(theme::section_spacing());
184
185 egui::CollapsingHeader::new("Tags")
186 .id_salt("detail_tags_section")
187 .default_open(true)
188 .show(ui, |ui| {
189 if state.selected_tags.is_empty() {
190 ui.label(egui::RichText::new("No tags").color(theme::text_muted()));
191 } else {
192 ui.horizontal_wrapped(|ui| {
193 let tags = state.selected_tags.clone();
194 for tag in tags.iter() {
195 if widgets::tag_chip_removable(ui, tag, true) {
196 // Remove tag and push an undoable entry so Cmd+Z restores it.
197 if let Some(ref hash) = node.node.sample_hash {
198 let hash_str = hash.to_string();
199 if state.backend.remove_tag(hash, tag).is_ok() {
200 state.push_undo(crate::state::UndoOp::TagRemove {
201 hash: hash_str,
202 tag: tag.clone(),
203 });
204 state.status = format!("Removed tag \"{tag}\"");
205 state.refresh_selected_tags();
206 }
207 }
208 }
209 }
210 });
211 }
212
213 // Tag input
214 ui.horizontal(|ui| {
215 let resp = ui.add(
216 egui::TextEdit::singleline(&mut state.tag_input)
217 .hint_text("Add tag (use dots: genre.house)")
218 .desired_width(ui.available_width() - 40.0),
219 );
220 // Honor the Tab-from-table shortcut: focus the tag input on this frame.
221 if state.focus_tag_input {
222 resp.request_focus();
223 state.focus_tag_input = false;
224 }
225 if (resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)))
226 || ui.small_button("+").on_hover_text("Add tag").clicked()
227 {
228 let tag = state.tag_input.trim().to_string();
229 if !tag.is_empty() {
230 if let Some(ref hash) = node.node.sample_hash {
231 if audiofiles_core::tags::validate_tag(&tag).is_ok() {
232 let _ = state.backend.add_tag(hash, &tag);
233 state.tag_input.clear();
234 state.refresh_selected_tags();
235 } else {
236 state.status = format!("Invalid tag: {tag}");
237 }
238 }
239 }
240 }
241 });
242
243 // Tag suggestions based on classification. Per-classification dismissals
244 // let the user say "I never tag kicks with `percussion`" once and have
245 // the suggestion stop appearing on every future kick.
246 if let Some(ref analysis) = state.selected_analysis {
247 if let Some(ref class) = analysis.classification {
248 let class_str = class.to_string();
249 let dismissed_for_class = state
250 .dismissed_suggestions
251 .get(&class_str)
252 .cloned()
253 .unwrap_or_default();
254 let suggestions: Vec<&'static str> =
255 classification_tag_suggestions(&class_str, &state.selected_tags)
256 .into_iter()
257 .filter(|s| !dismissed_for_class.iter().any(|d| d == s))
258 .collect();
259 if !suggestions.is_empty() {
260 ui.add_space(theme::space::SM);
261 ui.horizontal_wrapped(|ui| {
262 ui.label(
263 egui::RichText::new(format!("Suggest (from {class_str}):"))
264 .small()
265 .color(theme::text_muted()),
266 );
267 for sug in &suggestions {
268 if ui
269 .small_button(
270 egui::RichText::new(format!("+{sug}"))
271 .small()
272 .color(theme::accent_blue()),
273 )
274 .on_hover_text(format!("Add tag: {sug}"))
275 .clicked()
276 {
277 if let Some(ref hash) = node.node.sample_hash {
278 let _ = state.backend.add_tag(hash, sug);
279 state.refresh_selected_tags();
280 }
281 }
282 // Painted X (two crossed line_segments) — matches the
283 // Phase 4 M-8 X-icon precedent in instrument_panel.rs
284 // rather than a literal "x" glyph. Muted stroke since
285 // this is a secondary dismiss, not a danger action.
286 let icon_size = egui::vec2(14.0, 14.0);
287 let (icon_rect, icon_resp) =
288 ui.allocate_exact_size(icon_size, egui::Sense::click());
289 let icon_resp = icon_resp.on_hover_text(format!(
290 "Never suggest \"{sug}\" on {class_str} samples again"
291 ));
292 let pad = 3.5;
293 let p1 = icon_rect.min + egui::vec2(pad, pad);
294 let p2 = icon_rect.max - egui::vec2(pad, pad);
295 let p3 = egui::pos2(icon_rect.min.x + pad, icon_rect.max.y - pad);
296 let p4 = egui::pos2(icon_rect.max.x - pad, icon_rect.min.y + pad);
297 let stroke_color = if icon_resp.hovered() {
298 theme::text_secondary()
299 } else {
300 theme::text_muted()
301 };
302 let stroke = egui::Stroke::new(1.2, stroke_color);
303 let painter = ui.painter();
304 painter.line_segment([p1, p2], stroke);
305 painter.line_segment([p3, p4], stroke);
306 if icon_resp.clicked() {
307 state.dismiss_suggestion(&class_str, sug);
308 }
309 }
310 });
311 }
312
313 // M-1: inline Undo for the most recent dismiss. Visible for ~5s
314 // after the click so the affordance is at the locus of the action.
315 // Older dismissals still recoverable via Settings → Reset
316 // suggestions; this is just the fast-path for a stray click.
317 const UNDO_WINDOW: f32 = 5.0;
318 let show_undo = state
319 .last_dismissed_suggestion
320 .as_ref()
321 .filter(|(c, _, _)| c == &class_str)
322 .map(|(_, _, at)| at.elapsed().as_secs_f32() < UNDO_WINDOW);
323 if show_undo == Some(true) {
324 let (_, tag, _) = state
325 .last_dismissed_suggestion
326 .as_ref()
327 .expect("checked Some above")
328 .clone();
329 ui.add_space(theme::space::XS);
330 ui.horizontal(|ui| {
331 ui.label(
332 egui::RichText::new(format!("Muted \"{tag}\" for {class_str}."))
333 .small()
334 .color(theme::text_muted()),
335 );
336 if ui
337 .link(
338 egui::RichText::new("Undo")
339 .small()
340 .color(theme::accent_blue()),
341 )
342 .clicked()
343 {
344 state.undo_last_dismissal();
345 }
346 });
347 // Keep repainting so the affordance fades when the timer
348 // crosses 5s — without this the user could leave focus on a
349 // stale link.
350 ui.ctx().request_repaint();
351 }
352 }
353 }
354
355 }); // end of Tags CollapsingHeader
356
357 egui::CollapsingHeader::new("Actions")
358 .id_salt("detail_actions_section")
359 .default_open(true)
360 .show(ui, |ui| {
361 ui.horizontal(|ui| {
362 if ui.button("Copy Path").on_hover_text("Copy file path to clipboard").clicked() {
363 if let Some(path) = state.selected_sample_path() {
364 state.status = format!("Copied: {path}");
365 ui.ctx().copy_text(path);
366 }
367 }
368 if let Some(hash) = &node.node.sample_hash {
369 let hash = hash.clone();
370 if ui.button("Edit").on_hover_text("Open sample editor (E)").clicked() {
371 state.open_edit_window(&hash);
372 }
373 }
374 });
375 });
376
377 if let Some(hash) = &node.node.sample_hash {
378 let hash = hash.clone();
379 // M-10: gate Discovery on the analysis features each path needs.
380 // Find Similar reads spectral_centroid / spectral_bandwidth;
381 // Find Duplicates reads the peak-envelope fingerprint. Without
382 // these the button "works" but always returns zero results,
383 // which reads as a broken feature instead of a missing prereq.
384 let has_spectral = state
385 .selected_analysis
386 .as_ref()
387 .map(|a| a.spectral_centroid.is_some() || a.spectral_bandwidth.is_some())
388 .unwrap_or(false);
389 let has_fingerprint = state
390 .selected_analysis
391 .as_ref()
392 .map(|a| a.fingerprint.is_some())
393 .unwrap_or(false);
394 egui::CollapsingHeader::new("Discovery")
395 .id_salt("detail_discovery_section")
396 .default_open(true)
397 .show(ui, |ui| {
398 ui.horizontal(|ui| {
399 let similar_resp = ui.add_enabled(
400 has_spectral,
401 egui::Button::new("Find Similar"),
402 );
403 let similar_resp = if has_spectral {
404 similar_resp.on_hover_text("Find similar samples (Shift+F)")
405 } else {
406 similar_resp.on_disabled_hover_text(
407 "Re-analyze this sample with spectral features enabled to find similar samples.",
408 )
409 };
410 if similar_resp.clicked() {
411 state.find_similar(&hash);
412 }
413 let dup_resp = ui.add_enabled(
414 has_fingerprint,
415 egui::Button::new("Find Duplicates"),
416 );
417 let dup_resp = if has_fingerprint {
418 dup_resp.on_hover_text("Find near-duplicates (Shift+D)")
419 } else {
420 dup_resp.on_disabled_hover_text(
421 "Re-analyze this sample with fingerprinting enabled to find duplicates.",
422 )
423 };
424 if dup_resp.clicked() {
425 state.find_near_duplicates(&hash);
426 }
427 });
428 });
429 }
430 }
431
432 /// Draw a multi-selection summary: common metadata, union of tags, bulk-edit affordance.
433 fn draw_multi_summary(ui: &mut egui::Ui, state: &mut BrowserState) {
434 let nodes = state.selected_nodes();
435 let samples: Vec<_> = nodes
436 .iter()
437 .filter(|n| n.node.sample_hash.is_some())
438 .collect();
439 let sample_count = samples.len();
440 let folder_count = nodes.len().saturating_sub(sample_count);
441
442 let heading = if folder_count == 0 {
443 format!("{sample_count} samples selected")
444 } else {
445 format!(
446 "{sample_count} samples \u{00B7} {folder_count} folders selected",
447 )
448 };
449 ui.label(egui::RichText::new(heading).strong().size(14.0));
450 ui.add_space(theme::space::MD);
451
452 if sample_count == 0 {
453 widgets::empty_state(
454 ui,
455 "No sample metadata to summarize",
456 Some("Select one or more samples to see common fields"),
457 None,
458 );
459 return;
460 }
461
462 // Common metadata: show value if uniform across the selection, otherwise "varies".
463 fn summarize<T, F, V>(items: &[T], extract: F) -> Option<Result<V, ()>>
464 where
465 F: Fn(&T) -> Option<V>,
466 V: PartialEq,
467 {
468 let mut iter = items.iter().map(&extract);
469 let first = iter.next()??;
470 for v in iter {
471 match v {
472 Some(v) if v == first => continue,
473 Some(_) => return Some(Err(())),
474 None => return Some(Err(())),
475 }
476 }
477 Some(Ok(first))
478 }
479
480 ui.group(|ui| {
481 egui::Grid::new("detail_multi_metadata")
482 .num_columns(2)
483 .spacing([8.0, theme::grid_row_spacing()])
484 .show(ui, |ui| {
485 let bpm = summarize(&samples, |n| n.bpm);
486 ui.label(egui::RichText::new("BPM").color(theme::text_secondary()));
487 ui.label(match bpm {
488 Some(Ok(v)) => widgets::format_bpm(v),
489 Some(Err(())) => "varies".to_string(),
490 None => "\u{2014}".to_string(),
491 });
492 ui.end_row();
493
494 let key = summarize(&samples, |n| n.musical_key.clone());
495 ui.label(egui::RichText::new("Key").color(theme::text_secondary()));
496 ui.label(match key {
497 Some(Ok(v)) => v,
498 Some(Err(())) => "varies".to_string(),
499 None => "\u{2014}".to_string(),
500 });
501 ui.end_row();
502
503 let class = summarize(&samples, |n| n.classification.clone());
504 ui.label(egui::RichText::new("Class").color(theme::text_secondary()));
505 match class {
506 Some(Ok(v)) => widgets::classification_badge(ui, &v),
507 Some(Err(())) => {
508 ui.label("varies");
509 }
510 None => {
511 ui.label("\u{2014}");
512 }
513 }
514 ui.end_row();
515
516 let dur = summarize(&samples, |n| n.duration);
517 ui.label(egui::RichText::new("Duration").color(theme::text_secondary()));
518 ui.label(match dur {
519 Some(Ok(v)) => widgets::format_duration(v),
520 Some(Err(())) => "varies".to_string(),
521 None => "\u{2014}".to_string(),
522 });
523 ui.end_row();
524 });
525 });
526
527 ui.add_space(theme::section_spacing());
528 ui.separator();
529 ui.add_space(theme::section_spacing() * 0.5);
530
531 // Tag union with per-tag count badges.
532 widgets::subsection_label(ui, "Tags");
533 let mut tag_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
534 for n in &samples {
535 for tag in &n.tags {
536 *tag_counts.entry(tag.clone()).or_insert(0) += 1;
537 }
538 }
539 if tag_counts.is_empty() {
540 ui.label(egui::RichText::new("No tags").color(theme::text_muted()));
541 } else {
542 let mut entries: Vec<_> = tag_counts.into_iter().collect();
543 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
544 // Collect the hashes once so the closure that handles a badge click
545 // doesn't need to re-walk the selection. `samples` borrows from `nodes`
546 // which borrows from state — capture by value here so we can mutate
547 // state below.
548 let all_hashes: Vec<String> = samples
549 .iter()
550 .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string()))
551 .collect();
552 // M-11: actionable partial-coverage badges. Right-click any badge to
553 // apply / remove the tag across the selection. Full-coverage badges
554 // still render but expose only "Remove from all" (no Apply needed).
555 let mut pending_apply: Option<(String, Vec<String>)> = None;
556 let mut pending_remove: Option<(String, Vec<String>)> = None;
557 ui.horizontal_wrapped(|ui| {
558 for (tag, count) in entries {
559 let full = count == sample_count;
560 let label = if full {
561 tag.clone()
562 } else {
563 format!("{tag} ({count}/{sample_count})")
564 };
565 let hover = if full {
566 format!("\"{tag}\" \u{2014} on all {sample_count}. Right-click to remove from all.")
567 } else {
568 let missing = sample_count - count;
569 format!(
570 "\"{tag}\" \u{2014} on {count} of {sample_count}. Right-click to apply to remaining {missing} or remove from {count}."
571 )
572 };
573 let resp = ui
574 .label(
575 egui::RichText::new(label)
576 .small()
577 .color(theme::accent_blue()),
578 )
579 .on_hover_text(hover);
580 resp.context_menu(|ui| {
581 if !full {
582 let missing = sample_count - count;
583 if ui
584 .button(format!("Apply to remaining ({missing})"))
585 .clicked()
586 {
587 let targets: Vec<String> = samples
588 .iter()
589 .filter(|n| !n.tags.iter().any(|t| t == &tag))
590 .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string()))
591 .collect();
592 pending_apply = Some((tag.clone(), targets));
593 ui.close_menu();
594 }
595 }
596 let remove_label = if full {
597 format!("Remove from all ({count})")
598 } else {
599 format!("Remove from {count}")
600 };
601 if widgets::danger_button(ui, &remove_label).clicked() {
602 let targets: Vec<String> = samples
603 .iter()
604 .filter(|n| n.tags.iter().any(|t| t == &tag))
605 .filter_map(|n| n.node.sample_hash.as_ref().map(|h| h.to_string()))
606 .collect();
607 pending_remove = Some((tag.clone(), targets));
608 ui.close_menu();
609 }
610 });
611 }
612 });
613 let _ = all_hashes; // currently unused; reserved for future Apply-to-all path.
614 if let Some((tag, targets)) = pending_apply {
615 state.apply_tag_to_hashes(&tag, &targets);
616 } else if let Some((tag, targets)) = pending_remove {
617 state.remove_tag_from_hashes(&tag, &targets);
618 }
619 }
620
621 ui.add_space(theme::section_spacing());
622 ui.separator();
623 ui.add_space(theme::section_spacing() * 0.5);
624
625 if widgets::primary_button(ui, "Edit as bulk")
626 .on_hover_text("Add or remove a tag across the entire selection")
627 .clicked()
628 {
629 state.open_bulk_tag_modal();
630 }
631 }
632
633 /// Suggest tags based on the sample's classification. Excludes tags already applied.
634 fn classification_tag_suggestions(classification: &str, existing_tags: &[String]) -> Vec<&'static str> {
635 let candidates: &[&str] = match classification {
636 "kick" => &["drums.kick", "percussion", "one-shot"],
637 "snare" => &["drums.snare", "percussion", "one-shot"],
638 "hihat" => &["drums.hihat", "percussion", "one-shot"],
639 "cymbal" => &["drums.cymbal", "percussion", "one-shot"],
640 "percussion" => &["percussion", "one-shot"],
641 "bass" => &["bass", "synth.bass"],
642 "vocal" => &["vocal"],
643 "synth" => &["synth", "melodic"],
644 "pad" => &["synth.pad", "melodic", "texture"],
645 "fx" => &["fx", "texture"],
646 "noise" => &["noise", "texture"],
647 "music" => &["loop", "melodic"],
648 "ambience" => &["ambience", "texture", "field-recording"],
649 "impact" => &["fx.impact", "one-shot"],
650 "foley" => &["foley", "field-recording"],
651 "texture" => &["texture"],
652 _ => &[],
653 };
654
655 candidates
656 .iter()
657 .filter(|&&tag| !existing_tags.iter().any(|t| t == tag || t.starts_with(&format!("{tag}."))))
658 .copied()
659 .collect()
660 }
661