Skip to main content

max / audiofiles

13.0 KB · 373 lines History Blame Raw
1 use egui;
2
3 use crate::state::{BrowserState, CancelKind, ImportMode};
4
5 use super::super::{theme, widgets};
6
7 /// Render the accumulated import + analysis error log. Default-expanded so the
8 /// user sees actionable errors as they accumulate (M-1); a "Hide"/"Show" toggle
9 /// at the top-right of the section dismisses noise without losing the count.
10 fn draw_error_log(
11 ui: &mut egui::Ui,
12 expanded: &mut bool,
13 import_errors: &[crate::state::ImportFileError],
14 analysis_errors: &[crate::state::AnalysisFileError],
15 ) {
16 let err_count = import_errors.len() + analysis_errors.len();
17 if err_count == 0 {
18 return;
19 }
20 ui.add_space(theme::space::SM);
21 ui.horizontal(|ui| {
22 ui.label(
23 egui::RichText::new(format!(
24 "{err_count} error{}",
25 if err_count == 1 { "" } else { "s" },
26 ))
27 .color(theme::accent_red()),
28 );
29 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
30 let toggle_label = if *expanded { "Hide" } else { "Show" };
31 if ui
32 .small_button(toggle_label)
33 .on_hover_text("Toggle the error list")
34 .clicked()
35 {
36 *expanded = !*expanded;
37 }
38 });
39 });
40
41 if *expanded {
42 egui::ScrollArea::vertical()
43 .max_height(120.0)
44 .show(ui, |ui| {
45 for err in import_errors {
46 ui.label(
47 egui::RichText::new(format!("{}: {}", err.path, err.error))
48 .small()
49 .color(theme::accent_red()),
50 );
51 }
52 for err in analysis_errors {
53 ui.label(
54 egui::RichText::new(format!("{}: {}", err.name, err.error))
55 .small()
56 .color(theme::accent_red()),
57 );
58 }
59 });
60 }
61 }
62
63 /// Render the rate + ETA readout below a progress bar (M-11). Reads from the
64 /// rolling sample buffer on `state.operation_progress`; silently suppresses
65 /// itself until the buffer has enough data to predict.
66 fn draw_rate_and_eta(
67 ui: &mut egui::Ui,
68 state: &mut BrowserState,
69 completed: usize,
70 total: usize,
71 noun_per_sec: &str,
72 ) {
73 if let Some(progress) = state.operation_progress.as_mut() {
74 progress.record(completed);
75 let parts: Vec<String> = [
76 progress.rate().map(|r| format!("{r:.1} {noun_per_sec}")),
77 progress.eta(completed, total),
78 ]
79 .into_iter()
80 .flatten()
81 .collect();
82 if !parts.is_empty() {
83 ui.label(
84 egui::RichText::new(parts.join(" \u{00B7} "))
85 .small()
86 .color(theme::text_muted()),
87 );
88 }
89 }
90 }
91
92 /// Draw the folder import progress screen.
93 pub fn draw_import_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
94 let ctx = ui.ctx().clone();
95 let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) = match &state.import_mode {
96 ImportMode::Importing {
97 total,
98 completed,
99 current_name,
100 walking,
101 walking_count,
102 total_bytes,
103 loose_files,
104 } => (*total, *completed, current_name.clone(), *walking, *walking_count, *total_bytes, *loose_files),
105 _ => return,
106 };
107
108 egui::CentralPanel::default().show_inside(ui, |ui| {
109 // Keep the step rail visible during the import work (P5) — current step
110 // is still Configure, since import is the work that step kicks off.
111 widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
112 ui.heading("Importing Folder...");
113 ui.add_space(theme::space::LG);
114
115 if walking {
116 // m-12: running file count from throttled ImportWalkProgress
117 // events. Holds at "Scanning for audio files..." until the first
118 // event arrives so very fast walks don't flash a zero.
119 ui.horizontal(|ui| {
120 ui.spinner();
121 if walking_count > 0 {
122 let label = if total_bytes > 0 {
123 format!(
124 "Scanning for audio files... {walking_count} found ({})",
125 widgets::format_bytes(total_bytes),
126 )
127 } else {
128 format!("Scanning for audio files... {walking_count} found")
129 };
130 ui.label(label);
131 } else {
132 ui.label("Scanning for audio files...");
133 }
134 });
135 } else {
136 // Storage estimate
137 if total_bytes > 0 {
138 let size_label = widgets::format_bytes(total_bytes);
139 let storage_text = if loose_files {
140 format!("{total} files, {size_label} total (referenced in place, no copies)")
141 } else {
142 format!("{total} files, ~{size_label} will be duplicated into vault")
143 };
144 ui.label(
145 egui::RichText::new(storage_text)
146 .small()
147 .color(if loose_files { theme::accent_yellow() } else { theme::text_secondary() }),
148 );
149 ui.add_space(theme::space::SM);
150 }
151
152 let progress = if total > 0 {
153 completed as f32 / total as f32
154 } else {
155 0.0
156 };
157 let pct = (progress * 100.0) as u32;
158 ui.add(
159 egui::ProgressBar::new(progress)
160 .text(format!("{pct}% \u{2014} {completed}/{total} files")),
161 );
162 // Rate + ETA (M-11).
163 draw_rate_and_eta(ui, state, completed, total, "files/sec");
164
165 ui.add_space(theme::space::MD);
166 if !current_name.is_empty() {
167 ui.label(format!("Importing: {current_name}"));
168 }
169 }
170
171 // Error log: default-expanded so accumulating errors don't pile up
172 // behind a click (M-1). Hide toggle at the top-right.
173 draw_error_log(
174 ui,
175 &mut state.import_errors_expanded,
176 &state.import_file_errors,
177 &state.analysis_errors,
178 );
179 let err_count = state.import_file_errors.len() + state.analysis_errors.len();
180
181 ui.add_space(theme::space::SECTION);
182 ui.horizontal(|ui| {
183 // Cancel during the walking phase is disabled with an explanatory
184 // tooltip (M-2): cancel_import's interruption semantics for the
185 // walker are not guaranteed, and the walk usually completes in
186 // seconds anyway. Once walking finishes, Cancel becomes available.
187 if walking {
188 let _ = ui
189 .add_enabled(false, egui::Button::new("Cancel"))
190 .on_disabled_hover_text(
191 "Scanning — cancel available once the scan completes.",
192 );
193 } else if ui.button("Cancel").clicked() {
194 state.cancel_import();
195 }
196 if err_count > 0
197 && ui.button("Retry")
198 .on_hover_text("Cancel and re-open import configuration")
199 .clicked()
200 {
201 state.retry_import();
202 }
203 });
204 });
205
206 ctx.request_repaint();
207 }
208
209 /// Draw the cleanup (orphaned sample removal) progress screen.
210 pub fn draw_cleanup_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
211 let ctx = ui.ctx().clone();
212 let (completed, total, current_name) = match &state.import_mode {
213 ImportMode::Cleaning {
214 completed,
215 total,
216 current_name,
217 } => (*completed, *total, current_name.clone()),
218 _ => return,
219 };
220
221 egui::CentralPanel::default().show_inside(ui, |ui| {
222 ui.heading("Cleaning Up Samples...");
223 ui.add_space(theme::space::LG);
224
225 if total == 0 {
226 ui.horizontal(|ui| {
227 ui.spinner();
228 ui.label("Scanning for orphaned samples...");
229 });
230 } else {
231 let progress = completed as f32 / total as f32;
232 let pct = (progress * 100.0) as u32;
233 ui.add(
234 egui::ProgressBar::new(progress)
235 .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
236 );
237
238 ui.add_space(theme::space::MD);
239 if !current_name.is_empty() {
240 ui.label(format!("Removing: {current_name}"));
241 }
242 }
243
244 ui.add_space(theme::space::SECTION);
245 if ui.button("Cancel").clicked() {
246 state.cancel_cleanup();
247 }
248 });
249
250 ctx.request_repaint();
251 }
252
253 /// Draw the analysis progress screen.
254 pub fn draw_analysis_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
255 let ctx = ui.ctx().clone();
256 let (completed, total, current_name) = match &state.import_mode {
257 ImportMode::Analyzing {
258 completed,
259 total,
260 current_name,
261 } => (*completed, *total, current_name.clone()),
262 _ => return,
263 };
264
265 egui::CentralPanel::default().show_inside(ui, |ui| {
266 // Step rail stays visible on the slow Analyze screen, where orientation
267 // matters most (P5). Analyze is step index 2.
268 widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
269 ui.heading("Analyzing Samples...");
270 ui.add_space(theme::space::LG);
271
272 let progress = if total > 0 {
273 completed as f32 / total as f32
274 } else {
275 0.0
276 };
277 let pct = (progress * 100.0) as u32;
278 ui.add(
279 egui::ProgressBar::new(progress)
280 .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
281 );
282 // Rate + ETA (M-11).
283 draw_rate_and_eta(ui, state, completed, total, "samples/sec");
284
285 ui.add_space(theme::space::MD);
286 if !current_name.is_empty() {
287 ui.label(format!("Analysing: {current_name}"));
288 }
289
290 // Error log (M-1).
291 draw_error_log(
292 ui,
293 &mut state.import_errors_expanded,
294 &state.import_file_errors,
295 &state.analysis_errors,
296 );
297 let err_count = state.import_file_errors.len() + state.analysis_errors.len();
298
299 ui.add_space(theme::space::SECTION);
300 ui.horizontal(|ui| {
301 if ui.button("Cancel").clicked() {
302 state.cancel_analysis();
303 }
304 if err_count > 0
305 && ui.button("Retry")
306 .on_hover_text("Cancel and restart analysis")
307 .clicked()
308 {
309 state.retry_analysis();
310 }
311 });
312 });
313
314 ctx.request_repaint();
315 }
316
317 /// Acknowledgement screen shown after the user cancels a long-running import,
318 /// analysis, or export. Phase-5 C-3: cancelling shouldn't drop straight to
319 /// `None` — the user needs to know what landed vs what was discarded.
320 pub fn draw_operation_cancelled(ui: &mut egui::Ui, state: &mut BrowserState) {
321 let (kind, completed, total, destination) = match &state.import_mode {
322 ImportMode::OperationCancelled {
323 kind, completed, total, destination,
324 } => (*kind, *completed, *total, destination.clone()),
325 _ => return,
326 };
327
328 let (heading, noun, follow_up) = match kind {
329 CancelKind::Import => (
330 "Import cancelled",
331 "files",
332 "Imported files remain in the library. Re-run the import to add the rest \u{2014} duplicates will be skipped.",
333 ),
334 CancelKind::Analysis => (
335 "Analysis cancelled",
336 "samples",
337 "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} run analysis again to complete them.",
338 ),
339 CancelKind::Export => (
340 "Export cancelled",
341 "files",
342 "Files already written to the destination folder remain. A partial file for the in-progress item may also be present.",
343 ),
344 };
345
346 egui::CentralPanel::default().show_inside(ui, |ui| {
347 ui.heading(heading);
348 ui.add_space(theme::space::LG);
349 ui.label(
350 egui::RichText::new(format!("Stopped at {completed} of {total} {noun}."))
351 .strong(),
352 );
353 ui.add_space(theme::space::SM);
354 ui.label(
355 egui::RichText::new(follow_up)
356 .color(theme::text_secondary()),
357 );
358 if let Some(dest) = destination.as_ref() {
359 ui.add_space(theme::space::SM);
360 ui.label(
361 egui::RichText::new(format!("Destination: {}", dest.display()))
362 .small()
363 .color(theme::text_muted()),
364 );
365 }
366
367 ui.add_space(theme::space::SECTION);
368 if widgets::primary_button(ui, "Done").clicked() {
369 state.import_mode = ImportMode::None;
370 }
371 });
372 }
373