Skip to main content

max / audiofiles

13.1 KB · 344 lines History Blame Raw
1 //! Bottom footer: tag chips, transport controls, now-playing info, and status message.
2
3 use std::time::{Duration, Instant};
4
5 use egui;
6
7 use crate::state::BrowserState;
8 use super::theme;
9 use super::widgets;
10
11 /// Status message fade threshold — after this, status renders in `text_muted`.
12 const STATUS_FADE_AFTER: Duration = Duration::from_secs(5);
13 /// Status message hide threshold — after this, the status disappears entirely.
14 const STATUS_HIDE_AFTER: Duration = Duration::from_secs(30);
15
16 /// Heuristic: does this status line report a failure? Error statuses are kept
17 /// visible (never auto-hidden) and rendered in `accent_red`, since a silently
18 /// expiring error is the one message the user most needs to catch.
19 fn is_error_status(s: &str) -> bool {
20 let l = s.to_ascii_lowercase();
21 l.starts_with("failed")
22 || l.contains("error")
23 || l.starts_with("could not")
24 || l.starts_with("couldn't")
25 || l.starts_with("cannot")
26 || l.starts_with("can't")
27 }
28
29 /// Render a middle-dot section separator. Standardises the footer's
30 /// inter-section breaks on `\u{00B7}` (p-2) so the row reads as one
31 /// horizontal scan rather than a mix of vertical bars and dots.
32 fn dot(ui: &mut egui::Ui) {
33 ui.label(
34 egui::RichText::new("\u{00B7}")
35 .small()
36 .color(theme::text_muted()),
37 );
38 }
39
40 /// Draw the footer panel: transport, now-playing, tags, and status.
41 ///
42 /// M-8: when the window is too narrow to host every section in one row, split
43 /// into two rows — top row carries transport + status (the actively-changing
44 /// concerns), bottom row carries the more peripheral analysis-coverage /
45 /// selection-count / preview-device fields. Threshold ~1000px matches the
46 /// audit's recommendation and the empirical overflow point with the first-
47 /// launch hint visible.
48 pub fn draw_footer(ui: &mut egui::Ui, ctx: &egui::Context, state: &mut BrowserState) {
49 ui.add_space(theme::space::SM);
50
51 let narrow = ctx.content_rect().width() < 1000.0;
52
53 // Transport row
54 ui.horizontal(|ui| {
55 let playback = state.shared.preview.lock();
56 let playing = playback.playing;
57 let (position_secs, total_secs, progress) = if let Some(ref buf) = playback.buffer {
58 // Divide by 2: buffer is interleaved stereo (L, R, L, R, …),
59 // so frame count = sample count / 2 channels.
60 let total_frames = buf.data.len() / 2;
61 let sr = buf.sample_rate as f64;
62 let pos_s = playback.position_frac / sr;
63 let tot_s = total_frames as f64 / sr;
64 let prog = if total_frames > 0 {
65 (playback.position_frac / total_frames as f64) as f32
66 } else {
67 0.0
68 };
69 (pos_s as f32, tot_s as f32, prog)
70 } else {
71 (0.0, 0.0, 0.0)
72 };
73 drop(playback);
74
75 if playing {
76 if let Some(ref hash) = state.previewing_hash {
77 // Find name from contents
78 let name = state
79 .contents
80 .iter()
81 .find(|n| n.node.sample_hash.as_deref() == Some(hash))
82 .map(|n| n.node.name.as_str())
83 .unwrap_or("...");
84
85 // Classification badge
86 if let Some(ref analysis) = state.selected_analysis
87 && let Some(ref class) = analysis.classification {
88 widgets::classification_badge(ui, class.as_str());
89 }
90
91 ui.label(
92 egui::RichText::new(format!("Playing: {name}")).color(theme::text_primary()),
93 );
94
95 // Visual progress bar
96 let bar_width = 100.0;
97 let bar_height = 12.0;
98 let (rect, bar_resp) = ui.allocate_exact_size(
99 egui::vec2(bar_width, bar_height),
100 egui::Sense::click(),
101 );
102 if ui.is_rect_visible(rect) {
103 ui.painter().rect_filled(rect, 3.0, theme::bg_primary());
104 let fill_rect = egui::Rect::from_min_size(
105 rect.min,
106 egui::vec2(rect.width() * progress, rect.height()),
107 );
108 ui.painter().rect_filled(fill_rect, 3.0, theme::accent_blue());
109 }
110
111 // Click-to-seek on progress bar
112 if bar_resp.clicked()
113 && let Some(pos) = bar_resp.interact_pointer_pos() {
114 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
115 let mut playback = state.shared.preview.lock();
116 if let Some(ref buf) = playback.buffer {
117 let total_frames = buf.data.len() / 2;
118 playback.position_frac = normalized as f64 * total_frames as f64;
119 }
120 }
121
122 // Time display
123 ui.label(
124 egui::RichText::new(format!(
125 "{:.0}:{:02.0}/{:.0}:{:02.0}",
126 position_secs / 60.0,
127 position_secs % 60.0,
128 total_secs / 60.0,
129 total_secs % 60.0,
130 ))
131 .color(theme::text_secondary())
132 .small(),
133 );
134 }
135
136 if ui.small_button("Stop").on_hover_text("Stop preview (Space)").clicked() {
137 state.stop_preview();
138 }
139
140 ctx.request_repaint();
141 }
142
143 // Selection count — on narrow, deferred to the second row.
144 let sel_count = state.selection.count();
145 if !narrow && sel_count > 1 {
146 dot(ui);
147 ui.label(
148 egui::RichText::new(format!("{sel_count} selected"))
149 .color(theme::text_secondary()),
150 );
151 }
152
153 // M-13: detail-panel-hidden warning moved to a hover tooltip on the
154 // Detail toggle in toolbar.rs (where the action that triggered the
155 // toggle originated). Footer no longer hosts the message.
156
157 // Analysis coverage indicator — on narrow, deferred to the second row.
158 if !narrow {
159 dot(ui);
160 draw_analysis_coverage(ui, state);
161 }
162
163 // Status message — m-6: fade to muted after 5s, hide after 30s.
164 // Stamp `status_set_at` lazily for any caller that wrote `state.status`
165 // directly (legacy path); `post_status` callers stamp at write time.
166 // Detect changes since last frame via egui memory so the timer resets
167 // when an existing message is overwritten with a new one.
168 if !state.status.is_empty() {
169 let mem_id = egui::Id::new("footer_status_last_seen");
170 let prev: Option<String> = ui.ctx().data(|d| d.get_temp(mem_id));
171 let changed = prev.as_deref() != Some(state.status.as_str());
172 if changed {
173 state.status_set_at = Some(Instant::now());
174 ui.ctx().data_mut(|d| d.insert_temp(mem_id, state.status.clone()));
175 } else if state.status_set_at.is_none() {
176 state.status_set_at = Some(Instant::now());
177 }
178
179 let elapsed = state
180 .status_set_at
181 .map(|t| t.elapsed())
182 .unwrap_or_default();
183 // Errors stay up until replaced; informational/success messages
184 // fade after 5s and hide after 30s.
185 let is_error = is_error_status(&state.status);
186 if is_error || elapsed < STATUS_HIDE_AFTER {
187 dot(ui);
188 let color = if is_error {
189 theme::accent_red()
190 } else if elapsed >= STATUS_FADE_AFTER {
191 theme::text_muted()
192 } else {
193 theme::text_secondary()
194 };
195 ui.label(egui::RichText::new(&state.status).color(color));
196
197 // Request a repaint at the next fade/hide transition so they
198 // land on time even when the UI is idle. Errors don't expire,
199 // so they need no scheduled repaint.
200 if !is_error {
201 let next_threshold = if elapsed < STATUS_FADE_AFTER {
202 STATUS_FADE_AFTER - elapsed
203 } else {
204 STATUS_HIDE_AFTER - elapsed
205 };
206 ui.ctx().request_repaint_after(next_threshold);
207 }
208 }
209 } else if state.show_first_launch_hint {
210 // Clear stamp once the message has gone away so the next post
211 // starts a fresh timer.
212 state.status_set_at = None;
213 dot(ui);
214 ui.label(
215 egui::RichText::new("Right-click for options \u{00B7} F1 for shortcuts")
216 .small()
217 .color(theme::text_muted()),
218 );
219 if ui.small_button("Dismiss").on_hover_text("Dismiss").clicked() {
220 state.dismiss_first_launch_hint();
221 }
222 } else {
223 state.status_set_at = None;
224 }
225
226 // Preview output device — surfaced so a silent preview is diagnosable
227 // without opening Settings. Right-aligned so it doesn't fight with the
228 // status message on the left. Deferred to the second row when narrow.
229 if !narrow {
230 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
231 draw_preview_device(ui, state);
232 });
233 }
234 });
235
236 if narrow {
237 // M-8 second row: selection count, analysis coverage, preview device.
238 // Wrapped so a very narrow window flows them onto multiple sub-rows
239 // instead of clipping.
240 ui.horizontal_wrapped(|ui| {
241 let sel_count = state.selection.count();
242 if sel_count > 1 {
243 ui.label(
244 egui::RichText::new(format!("{sel_count} selected"))
245 .small()
246 .color(theme::text_secondary()),
247 );
248 dot(ui);
249 }
250 draw_analysis_coverage(ui, state);
251 dot(ui);
252 draw_preview_device(ui, state);
253 });
254 }
255
256 // Tags row for selected sample. m-13: render as plain muted text rather
257 // than chip-styled labels — these are inert (informational only), so the
258 // affordance contract should not invite a click. Sidebar / detail panel
259 // remain the canonical clickable-tag surfaces.
260 if !state.selected_tags.is_empty() {
261 ui.horizontal_wrapped(|ui| {
262 ui.spacing_mut().item_spacing.x = 8.0;
263 for (i, tag) in state.selected_tags.iter().enumerate() {
264 if i > 0 {
265 ui.label(
266 egui::RichText::new("\u{00B7}")
267 .small()
268 .color(theme::text_muted()),
269 );
270 }
271 ui.label(
272 egui::RichText::new(tag)
273 .small()
274 .color(theme::text_muted()),
275 );
276 }
277 });
278 }
279
280 ui.add_space(theme::space::XS);
281 }
282
283 /// Render the analysis coverage chips. Caller adds the leading separator
284 /// (`dot(ui)`) when the section follows other content.
285 fn draw_analysis_coverage(ui: &mut egui::Ui, state: &BrowserState) {
286 let total_samples = state
287 .contents
288 .iter()
289 .filter(|n| n.node.sample_hash.is_some())
290 .count();
291 if total_samples == 0 {
292 return;
293 }
294 let analyzed = state
295 .contents
296 .iter()
297 .filter(|n| n.node.sample_hash.is_some() && n.duration.is_some())
298 .count();
299 let untagged = state
300 .contents
301 .iter()
302 .filter(|n| n.node.sample_hash.is_some() && n.tags.is_empty())
303 .count();
304 if analyzed < total_samples {
305 ui.label(
306 egui::RichText::new(format!("{analyzed}/{total_samples} analyzed"))
307 .small()
308 .color(theme::text_muted()),
309 );
310 } else {
311 ui.label(
312 egui::RichText::new(format!("{total_samples} analyzed"))
313 .small()
314 .color(theme::accent_green()),
315 );
316 }
317 // m-12: suppress untagged count until analysis has produced output.
318 if analyzed > 0 && untagged > 0 {
319 ui.label(
320 egui::RichText::new(format!("\u{00B7} {untagged} untagged"))
321 .small()
322 .color(theme::text_muted()),
323 );
324 }
325 }
326
327 /// Render the preview output device chip. Caller controls the layout
328 /// direction (right-to-left on the wide footer, default on the narrow row).
329 fn draw_preview_device(ui: &mut egui::Ui, state: &BrowserState) {
330 let device_label = state
331 .shared
332 .preview_device_name
333 .lock()
334 .clone()
335 .map(|name| format!("Preview: {name}"))
336 .unwrap_or_else(|| "Preview: no device".to_string());
337 ui.label(
338 egui::RichText::new(device_label)
339 .small()
340 .color(theme::text_muted()),
341 )
342 .on_hover_text("Audio output device used for sample preview");
343 }
344