//! Bottom footer: tag chips, transport controls, now-playing info, and status message. use std::time::{Duration, Instant}; use egui; use crate::state::BrowserState; use super::theme; use super::widgets; /// Status message fade threshold — after this, status renders in `text_muted`. const STATUS_FADE_AFTER: Duration = Duration::from_secs(5); /// Status message hide threshold — after this, the status disappears entirely. const STATUS_HIDE_AFTER: Duration = Duration::from_secs(30); /// Heuristic: does this status line report a failure? Error statuses are kept /// visible (never auto-hidden) and rendered in `accent_red`, since a silently /// expiring error is the one message the user most needs to catch. fn is_error_status(s: &str) -> bool { let l = s.to_ascii_lowercase(); l.starts_with("failed") || l.contains("error") || l.starts_with("could not") || l.starts_with("couldn't") || l.starts_with("cannot") || l.starts_with("can't") } /// Render a middle-dot section separator. Standardises the footer's /// inter-section breaks on `\u{00B7}` (p-2) so the row reads as one /// horizontal scan rather than a mix of vertical bars and dots. fn dot(ui: &mut egui::Ui) { ui.label( egui::RichText::new("\u{00B7}") .small() .color(theme::text_muted()), ); } /// Draw the footer panel: transport, now-playing, tags, and status. /// /// M-8: when the window is too narrow to host every section in one row, split /// into two rows — top row carries transport + status (the actively-changing /// concerns), bottom row carries the more peripheral analysis-coverage / /// selection-count / preview-device fields. Threshold ~1000px matches the /// audit's recommendation and the empirical overflow point with the first- /// launch hint visible. pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserState) { ui.add_space(theme::space::SM); let narrow = ctx.content_rect().width() < 1000.0; // Transport row ui.horizontal(|ui| { let playback = state.shared.preview.lock(); let playing = playback.playing; let (position_secs, total_secs, progress) = if let Some(ref buf) = playback.buffer { // Divide by 2: buffer is interleaved stereo (L, R, L, R, …), // so frame count = sample count / 2 channels. let total_frames = buf.data.len() / 2; let sr = buf.sample_rate as f64; let pos_s = playback.position_frac / sr; let tot_s = total_frames as f64 / sr; let prog = if total_frames > 0 { (playback.position_frac / total_frames as f64) as f32 } else { 0.0 }; (pos_s as f32, tot_s as f32, prog) } else { (0.0, 0.0, 0.0) }; drop(playback); if playing { if let Some(ref hash) = state.previewing_hash { // Find name from contents let name = state .contents .iter() .find(|n| n.node.sample_hash.as_deref() == Some(hash)) .map(|n| n.node.name.as_str()) .unwrap_or("..."); // Classification badge if let Some(ref analysis) = state.selected_analysis && let Some(ref class) = analysis.classification { widgets::classification_badge(ui, class.as_str()); } ui.label( egui::RichText::new(format!("Playing: {name}")).color(theme::text_primary()), ); // Visual progress bar let bar_width = 100.0; let bar_height = 12.0; let (rect, bar_resp) = ui.allocate_exact_size( egui::vec2(bar_width, bar_height), egui::Sense::click(), ); if ui.is_rect_visible(rect) { ui.painter().rect_filled(rect, 3.0, theme::bg_primary()); let fill_rect = egui::Rect::from_min_size( rect.min, egui::vec2(rect.width() * progress, rect.height()), ); ui.painter().rect_filled(fill_rect, 3.0, theme::accent_blue()); } // Click-to-seek on progress bar if bar_resp.clicked() && let Some(pos) = bar_resp.interact_pointer_pos() { let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0); let mut playback = state.shared.preview.lock(); if let Some(ref buf) = playback.buffer { let total_frames = buf.data.len() / 2; playback.position_frac = normalized as f64 * total_frames as f64; } } // Time display ui.label( egui::RichText::new(format!( "{:.0}:{:02.0}/{:.0}:{:02.0}", position_secs / 60.0, position_secs % 60.0, total_secs / 60.0, total_secs % 60.0, )) .color(theme::text_secondary()) .small(), ); } if ui.small_button("Stop").on_hover_text("Stop preview (Space)").clicked() { state.stop_preview(); } ctx.request_repaint(); } // Selection count — on narrow, deferred to the second row. let sel_count = state.selection.count(); if !narrow && sel_count > 1 { dot(ui); ui.label( egui::RichText::new(format!("{sel_count} selected")) .color(theme::text_secondary()), ); } // M-13: detail-panel-hidden warning moved to a hover tooltip on the // Detail toggle in toolbar.rs (where the action that triggered the // toggle originated). Footer no longer hosts the message. // Analysis coverage indicator — on narrow, deferred to the second row. if !narrow { dot(ui); draw_analysis_coverage(ui, state); } // Status message — m-6: fade to muted after 5s, hide after 30s. // Stamp `status_set_at` lazily for any caller that wrote `state.status` // directly (legacy path); `post_status` callers stamp at write time. // Detect changes since last frame via egui memory so the timer resets // when an existing message is overwritten with a new one. if !state.status.is_empty() { let mem_id = egui::Id::new("footer_status_last_seen"); let prev: Option = ui.ctx().data(|d| d.get_temp(mem_id)); let changed = prev.as_deref() != Some(state.status.as_str()); if changed { state.status_set_at = Some(Instant::now()); ui.ctx().data_mut(|d| d.insert_temp(mem_id, state.status.clone())); } else if state.status_set_at.is_none() { state.status_set_at = Some(Instant::now()); } let elapsed = state .status_set_at .map(|t| t.elapsed()) .unwrap_or_default(); // Errors stay up until replaced; informational/success messages // fade after 5s and hide after 30s. let is_error = is_error_status(&state.status); if is_error || elapsed < STATUS_HIDE_AFTER { dot(ui); let color = if is_error { theme::accent_red() } else if elapsed >= STATUS_FADE_AFTER { theme::text_muted() } else { theme::text_secondary() }; ui.label(egui::RichText::new(&state.status).color(color)); // Request a repaint at the next fade/hide transition so they // land on time even when the UI is idle. Errors don't expire, // so they need no scheduled repaint. if !is_error { let next_threshold = if elapsed < STATUS_FADE_AFTER { STATUS_FADE_AFTER - elapsed } else { STATUS_HIDE_AFTER - elapsed }; ui.ctx().request_repaint_after(next_threshold); } } } else if state.show_first_launch_hint { // Clear stamp once the message has gone away so the next post // starts a fresh timer. state.status_set_at = None; dot(ui); ui.label( egui::RichText::new("Right-click for options \u{00B7} F1 for shortcuts") .small() .color(theme::text_muted()), ); if ui.small_button("Dismiss").on_hover_text("Dismiss").clicked() { state.dismiss_first_launch_hint(); } } else { state.status_set_at = None; } // Preview output device — surfaced so a silent preview is diagnosable // without opening Settings. Right-aligned so it doesn't fight with the // status message on the left. Deferred to the second row when narrow. if !narrow { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { draw_preview_device(ui, state); }); } }); if narrow { // M-8 second row: selection count, analysis coverage, preview device. // Wrapped so a very narrow window flows them onto multiple sub-rows // instead of clipping. ui.horizontal_wrapped(|ui| { let sel_count = state.selection.count(); if sel_count > 1 { ui.label( egui::RichText::new(format!("{sel_count} selected")) .small() .color(theme::text_secondary()), ); dot(ui); } draw_analysis_coverage(ui, state); dot(ui); draw_preview_device(ui, state); }); } // Tags row for selected sample. m-13: render as plain muted text rather // than chip-styled labels — these are inert (informational only), so the // affordance contract should not invite a click. Sidebar / detail panel // remain the canonical clickable-tag surfaces. if !state.selected_tags.is_empty() { ui.horizontal_wrapped(|ui| { ui.spacing_mut().item_spacing.x = 8.0; for (i, tag) in state.selected_tags.iter().enumerate() { if i > 0 { ui.label( egui::RichText::new("\u{00B7}") .small() .color(theme::text_muted()), ); } ui.label( egui::RichText::new(tag) .small() .color(theme::text_muted()), ); } }); } ui.add_space(theme::space::XS); } /// Render the analysis coverage chips. Caller adds the leading separator /// (`dot(ui)`) when the section follows other content. fn draw_analysis_coverage(ui: &mut egui::Ui, state: &BrowserState) { let total_samples = state .contents .iter() .filter(|n| n.node.sample_hash.is_some()) .count(); if total_samples == 0 { return; } let analyzed = state .contents .iter() .filter(|n| n.node.sample_hash.is_some() && n.duration.is_some()) .count(); let untagged = state .contents .iter() .filter(|n| n.node.sample_hash.is_some() && n.tags.is_empty()) .count(); if analyzed < total_samples { ui.label( egui::RichText::new(format!("{analyzed}/{total_samples} analyzed")) .small() .color(theme::text_muted()), ); } else { ui.label( egui::RichText::new(format!("{total_samples} analyzed")) .small() .color(theme::accent_green()), ); } // m-12: suppress untagged count until analysis has produced output. if analyzed > 0 && untagged > 0 { ui.label( egui::RichText::new(format!("\u{00B7} {untagged} untagged")) .small() .color(theme::text_muted()), ); } } /// Render the preview output device chip. Caller controls the layout /// direction (right-to-left on the wide footer, default on the narrow row). fn draw_preview_device(ui: &mut egui::Ui, state: &BrowserState) { let device_label = state .shared .preview_device_name .lock() .clone() .map(|name| format!("Preview: {name}")) .unwrap_or_else(|| "Preview: no device".to_string()); ui.label( egui::RichText::new(device_label) .small() .color(theme::text_muted()), ) .on_hover_text("Audio output device used for sample preview"); }