Skip to main content

max / audiofiles

12.5 KB · 364 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(ctx: &egui::Context, state: &mut BrowserState) {
94 let (total, completed, current_name, walking, walking_count, total_bytes, loose_files) = match &state.import_mode {
95 ImportMode::Importing {
96 total,
97 completed,
98 current_name,
99 walking,
100 walking_count,
101 total_bytes,
102 loose_files,
103 } => (*total, *completed, current_name.clone(), *walking, *walking_count, *total_bytes, *loose_files),
104 _ => return,
105 };
106
107 egui::CentralPanel::default().show(ctx, |ui| {
108 ui.heading("Importing Folder...");
109 ui.add_space(theme::space::LG);
110
111 if walking {
112 // m-12: running file count from throttled ImportWalkProgress
113 // events. Holds at "Scanning for audio files..." until the first
114 // event arrives so very fast walks don't flash a zero.
115 ui.horizontal(|ui| {
116 ui.spinner();
117 if walking_count > 0 {
118 let label = if total_bytes > 0 {
119 format!(
120 "Scanning for audio files... {walking_count} found ({})",
121 widgets::format_bytes(total_bytes),
122 )
123 } else {
124 format!("Scanning for audio files... {walking_count} found")
125 };
126 ui.label(label);
127 } else {
128 ui.label("Scanning for audio files...");
129 }
130 });
131 } else {
132 // Storage estimate
133 if total_bytes > 0 {
134 let size_label = widgets::format_bytes(total_bytes);
135 let storage_text = if loose_files {
136 format!("{total} files, {size_label} total (referenced in place, no copies)")
137 } else {
138 format!("{total} files, ~{size_label} will be duplicated into vault")
139 };
140 ui.label(
141 egui::RichText::new(storage_text)
142 .small()
143 .color(if loose_files { theme::accent_yellow() } else { theme::text_secondary() }),
144 );
145 ui.add_space(theme::space::SM);
146 }
147
148 let progress = if total > 0 {
149 completed as f32 / total as f32
150 } else {
151 0.0
152 };
153 let pct = (progress * 100.0) as u32;
154 ui.add(
155 egui::ProgressBar::new(progress)
156 .text(format!("{pct}% \u{2014} {completed}/{total} files")),
157 );
158 // Rate + ETA (M-11).
159 draw_rate_and_eta(ui, state, completed, total, "files/sec");
160
161 ui.add_space(theme::space::MD);
162 if !current_name.is_empty() {
163 ui.label(format!("Importing: {current_name}"));
164 }
165 }
166
167 // Error log: default-expanded so accumulating errors don't pile up
168 // behind a click (M-1). Hide toggle at the top-right.
169 draw_error_log(
170 ui,
171 &mut state.import_errors_expanded,
172 &state.import_file_errors,
173 &state.analysis_errors,
174 );
175 let err_count = state.import_file_errors.len() + state.analysis_errors.len();
176
177 ui.add_space(theme::space::SECTION);
178 ui.horizontal(|ui| {
179 // Cancel during the walking phase is disabled with an explanatory
180 // tooltip (M-2): cancel_import's interruption semantics for the
181 // walker are not guaranteed, and the walk usually completes in
182 // seconds anyway. Once walking finishes, Cancel becomes available.
183 if walking {
184 let _ = ui
185 .add_enabled(false, egui::Button::new("Cancel"))
186 .on_disabled_hover_text(
187 "Scanning — cancel available once the scan completes.",
188 );
189 } else if ui.button("Cancel").clicked() {
190 state.cancel_import();
191 }
192 if err_count > 0
193 && ui.button("Retry")
194 .on_hover_text("Cancel and re-open import configuration")
195 .clicked()
196 {
197 state.retry_import();
198 }
199 });
200 });
201
202 ctx.request_repaint();
203 }
204
205 /// Draw the cleanup (orphaned sample removal) progress screen.
206 pub fn draw_cleanup_progress(ctx: &egui::Context, state: &mut BrowserState) {
207 let (completed, total, current_name) = match &state.import_mode {
208 ImportMode::Cleaning {
209 completed,
210 total,
211 current_name,
212 } => (*completed, *total, current_name.clone()),
213 _ => return,
214 };
215
216 egui::CentralPanel::default().show(ctx, |ui| {
217 ui.heading("Cleaning Up Samples...");
218 ui.add_space(theme::space::LG);
219
220 if total == 0 {
221 ui.horizontal(|ui| {
222 ui.spinner();
223 ui.label("Scanning for orphaned samples...");
224 });
225 } else {
226 let progress = completed as f32 / total as f32;
227 let pct = (progress * 100.0) as u32;
228 ui.add(
229 egui::ProgressBar::new(progress)
230 .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
231 );
232
233 ui.add_space(theme::space::MD);
234 if !current_name.is_empty() {
235 ui.label(format!("Removing: {current_name}"));
236 }
237 }
238
239 ui.add_space(theme::space::SECTION);
240 if ui.button("Cancel").clicked() {
241 state.cancel_cleanup();
242 }
243 });
244
245 ctx.request_repaint();
246 }
247
248 /// Draw the analysis progress screen.
249 pub fn draw_analysis_progress(ctx: &egui::Context, state: &mut BrowserState) {
250 let (completed, total, current_name) = match &state.import_mode {
251 ImportMode::Analyzing {
252 completed,
253 total,
254 current_name,
255 } => (*completed, *total, current_name.clone()),
256 _ => return,
257 };
258
259 egui::CentralPanel::default().show(ctx, |ui| {
260 ui.heading("Analyzing Samples...");
261 ui.add_space(theme::space::LG);
262
263 let progress = if total > 0 {
264 completed as f32 / total as f32
265 } else {
266 0.0
267 };
268 let pct = (progress * 100.0) as u32;
269 ui.add(
270 egui::ProgressBar::new(progress)
271 .text(format!("{pct}% \u{2014} {completed}/{total} samples")),
272 );
273 // Rate + ETA (M-11).
274 draw_rate_and_eta(ui, state, completed, total, "samples/sec");
275
276 ui.add_space(theme::space::MD);
277 if !current_name.is_empty() {
278 ui.label(format!("Analysing: {current_name}"));
279 }
280
281 // Error log (M-1).
282 draw_error_log(
283 ui,
284 &mut state.import_errors_expanded,
285 &state.import_file_errors,
286 &state.analysis_errors,
287 );
288 let err_count = state.import_file_errors.len() + state.analysis_errors.len();
289
290 ui.add_space(theme::space::SECTION);
291 ui.horizontal(|ui| {
292 if ui.button("Cancel").clicked() {
293 state.cancel_analysis();
294 }
295 if err_count > 0
296 && ui.button("Retry")
297 .on_hover_text("Cancel and restart analysis")
298 .clicked()
299 {
300 state.retry_analysis();
301 }
302 });
303 });
304
305 ctx.request_repaint();
306 }
307
308 /// Acknowledgement screen shown after the user cancels a long-running import,
309 /// analysis, or export. Phase-5 C-3: cancelling shouldn't drop straight to
310 /// `None` — the user needs to know what landed vs what was discarded.
311 pub fn draw_operation_cancelled(ctx: &egui::Context, state: &mut BrowserState) {
312 let (kind, completed, total, destination) = match &state.import_mode {
313 ImportMode::OperationCancelled {
314 kind, completed, total, destination,
315 } => (*kind, *completed, *total, destination.clone()),
316 _ => return,
317 };
318
319 let (heading, noun, follow_up) = match kind {
320 CancelKind::Import => (
321 "Import cancelled",
322 "files",
323 "Imported files remain in the library. Re-run the import to add the rest \u{2014} duplicates will be skipped.",
324 ),
325 CancelKind::Analysis => (
326 "Analysis cancelled",
327 "samples",
328 "Analysed samples keep their results. The remaining samples are unanalysed \u{2014} run analysis again to complete them.",
329 ),
330 CancelKind::Export => (
331 "Export cancelled",
332 "files",
333 "Files already written to the destination folder remain. A partial file for the in-progress item may also be present.",
334 ),
335 };
336
337 egui::CentralPanel::default().show(ctx, |ui| {
338 ui.heading(heading);
339 ui.add_space(theme::space::LG);
340 ui.label(
341 egui::RichText::new(format!("Stopped at {completed} of {total} {noun}."))
342 .strong(),
343 );
344 ui.add_space(theme::space::SM);
345 ui.label(
346 egui::RichText::new(follow_up)
347 .color(theme::text_secondary()),
348 );
349 if let Some(dest) = destination.as_ref() {
350 ui.add_space(theme::space::SM);
351 ui.label(
352 egui::RichText::new(format!("Destination: {}", dest.display()))
353 .small()
354 .color(theme::text_muted()),
355 );
356 }
357
358 ui.add_space(theme::space::SECTION);
359 if widgets::primary_button(ui, "Done").clicked() {
360 state.import_mode = ImportMode::None;
361 }
362 });
363 }
364