Skip to main content

max / audiofiles

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