Skip to main content

max / audiofiles

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