Skip to main content

max / audiofiles

27.4 KB · 632 lines History Blame Raw
1 //! Export workflow screens: configure export settings, progress bar, and completion summary.
2
3 use std::path::Path;
4
5 use egui;
6
7 use crate::state::{BrowserState, ImportMode};
8 use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat};
9
10 use super::{theme, widgets};
11
12 /// Query available disk space on the filesystem containing the given path.
13 #[cfg(unix)]
14 fn available_disk_space(path: &Path) -> Option<u64> {
15 use std::ffi::CString;
16 use std::os::unix::ffi::OsStrExt;
17
18 let c_path = CString::new(path.as_os_str().as_bytes()).ok()?;
19 // SAFETY: `statvfs` is a POSIX FFI call. `c_path` is a valid NUL-terminated
20 // C string (from CString::new). `stat` is zero-initialized, which is a valid
21 // representation for libc::statvfs. The pointer to `stat` is valid for the
22 // duration of the call.
23 unsafe {
24 let mut stat: libc::statvfs = std::mem::zeroed();
25 if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 {
26 Some(stat.f_bavail as u64 * stat.f_frsize as u64)
27 } else {
28 None
29 }
30 }
31 }
32
33 #[cfg(windows)]
34 fn available_disk_space(path: &Path) -> Option<u64> {
35 use std::os::windows::ffi::OsStrExt;
36 let wide: Vec<u16> = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect();
37 let mut free_bytes: u64 = 0;
38 // SAFETY: `GetDiskFreeSpaceExW` is a Win32 FFI call. `wide` is a valid
39 // NUL-terminated UTF-16 string (from encode_wide + chain(once(0))).
40 // `free_bytes` is a valid aligned u64 for the out-parameter. The pointer
41 // to `wide` is valid for the duration of the call.
42 unsafe {
43 if windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
44 windows::core::PCWSTR(wide.as_ptr()),
45 Some(&mut free_bytes),
46 None,
47 None,
48 ).is_ok() {
49 Some(free_bytes)
50 } else {
51 None
52 }
53 }
54 }
55
56 #[cfg(not(any(unix, windows)))]
57 fn available_disk_space(_path: &Path) -> Option<u64> {
58 None
59 }
60
61 /// Effective bytes-per-second of audio under the current export config.
62 /// Used by the disk-space and AIFF-size pre-flight warnings (M-3 / M-4) so
63 /// the magnitude warnings reflect the user's actual selection rather than a
64 /// worst-case heuristic. Defaults (`None` config values) bias high so we err
65 /// on the side of warning when the user picks "Original".
66 fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 {
67 let rate = config.sample_rate.unwrap_or(48_000) as u64;
68 let depth_bytes = (config.bit_depth.unwrap_or(24) as u64).div_ceil(8);
69 let channels = match config.channels {
70 ExportChannels::Mono => 1u64,
71 ExportChannels::Stereo => 2u64,
72 ExportChannels::Original => 2u64,
73 };
74 rate.saturating_mul(depth_bytes).saturating_mul(channels)
75 }
76
77 /// Draw the export configuration screen.
78 pub fn draw_configure_export(ctx: &egui::Context, state: &mut BrowserState) {
79 let (item_count, profile_count) = match &state.import_mode {
80 ImportMode::ConfigureExport {
81 items,
82 available_profiles,
83 ..
84 } => (items.len(), available_profiles.len()),
85 _ => return,
86 };
87
88 egui::TopBottomPanel::bottom("export_footer").show(ctx, |ui| {
89 ui.add_space(theme::space::SM);
90
91 // Warnings
92 if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_mode {
93 // AIFF size limit warning (M-4): the 4 GB chunk limit translates
94 // to ~124 minutes at the worst-case config (stereo 24-bit 96kHz)
95 // and considerably more at smaller depths/rates. Compute the
96 // actual safe duration from the current config rather than warning
97 // at a fixed 20-minute threshold. Yellow because this is
98 // anticipation, not error.
99 if config.format == ExportFormat::Aiff {
100 let max_duration = items.iter().filter_map(|i| i.duration).fold(0.0f64, f64::max);
101 let bps = bytes_per_sec_for_config(config) as f64;
102 // 90% of u32::MAX gives headroom for chunk headers + rounding.
103 let safe_secs = (u32::MAX as f64 * 0.9) / bps.max(1.0);
104 if max_duration > safe_secs {
105 ui.label(
106 egui::RichText::new(format!(
107 "Warning: AIFF chunks cap at 4 GB. At the current rate/depth/channels, \
108 samples longer than ~{:.0} min may fail to export.",
109 safe_secs / 60.0,
110 ))
111 .small()
112 .color(theme::accent_yellow()),
113 );
114 }
115 }
116
117 // Device file size limit warning
118 if let Some(ref profile_name) = config.device_profile {
119 if let Some(profile) = available_profiles.iter().find(|p| &p.name == profile_name) {
120 if let Some(max_bytes) = profile.max_file_size_bytes {
121 // Estimate: duration * sample_rate * channels * bytes_per_sample
122 // Use worst case: stereo 24-bit at 48kHz = 288000 bytes/sec
123 let bytes_per_sec: f64 = 288_000.0;
124 let over_limit: Vec<&str> = items.iter()
125 .filter(|item| {
126 item.duration
127 .map(|d| (d * bytes_per_sec) as u64 > max_bytes)
128 .unwrap_or(false)
129 })
130 .map(|item| item.name.as_str())
131 .collect();
132 if !over_limit.is_empty() {
133 let msg = if over_limit.len() == 1 {
134 format!(
135 "\"{}\" may exceed device file size limit ({:.0} MB)",
136 over_limit[0],
137 max_bytes as f64 / 1_048_576.0,
138 )
139 } else {
140 format!(
141 "{} samples may exceed device file size limit ({:.0} MB)",
142 over_limit.len(),
143 max_bytes as f64 / 1_048_576.0,
144 )
145 };
146 ui.label(
147 egui::RichText::new(msg).small().color(theme::accent_red()),
148 );
149 }
150 }
151 }
152 }
153
154 // Disk space check (M-3): estimate from actual per-item durations
155 // and the current encoding config rather than a fixed 10 MB/item
156 // heuristic. Only warn when the projection exceeds available space
157 // with a 10% headroom. Yellow because this is an anticipation
158 // warning, not a confirmed failure.
159 if let Some(available) = available_disk_space(&config.destination) {
160 let bps = bytes_per_sec_for_config(config);
161 let estimated_bytes: u64 = items
162 .iter()
163 .filter_map(|i| i.duration)
164 .map(|d| (d.max(0.0) * bps as f64) as u64)
165 .sum();
166 if estimated_bytes > 0 && (estimated_bytes as f64) * 1.1 > available as f64 {
167 ui.label(
168 egui::RichText::new(format!(
169 "Low disk space: {:.1} GB available, ~{:.1} GB needed",
170 available as f64 / 1_073_741_824.0,
171 estimated_bytes as f64 / 1_073_741_824.0,
172 ))
173 .small()
174 .color(theme::accent_yellow()),
175 );
176 }
177 }
178 }
179
180 ui.horizontal(|ui| {
181 if ui.button("Cancel").clicked() {
182 state.import_mode = ImportMode::None;
183 }
184 if ui.button("Export").clicked() {
185 if let ImportMode::ConfigureExport { ref items, ref config, .. } =
186 state.import_mode
187 {
188 let items = items.clone();
189 let config = config.clone();
190 state.run_export(items, config);
191 }
192 }
193 });
194 ui.add_space(theme::space::XS);
195 });
196
197 egui::CentralPanel::default().show(ctx, |ui| {
198 egui::ScrollArea::vertical().show(ui, |ui| {
199 ui.heading("Export Samples");
200 ui.add_space(theme::space::SM);
201 ui.horizontal(|ui| {
202 ui.label(format!("{item_count} samples to export"));
203 if profile_count > 0 {
204 ui.label(
205 egui::RichText::new(format!("\u{00B7} {} device profiles available", profile_count))
206 .small()
207 .color(theme::text_muted()),
208 );
209 }
210 });
211 ui.add_space(theme::space::LG);
212
213 // --- Device Profile ---
214 if profile_count > 0 {
215 ui.label(egui::RichText::new("Device Profile").strong());
216 if let ImportMode::ConfigureExport {
217 ref mut config,
218 ref available_profiles,
219 ..
220 } = state.import_mode
221 {
222 let current_label = config
223 .device_profile
224 .as_deref()
225 .unwrap_or("None (manual)");
226
227 egui::ComboBox::from_id_salt("device_profile_picker")
228 .selected_text(current_label)
229 .width(250.0)
230 .show_ui(ui, |ui| {
231 if ui
232 .selectable_value(
233 &mut config.device_profile,
234 None,
235 "None (manual)",
236 )
237 .clicked()
238 {
239 // Reset profile-derived fields when switching to manual
240 config.naming_rules = None;
241 config.max_file_size_bytes = None;
242 config.name_overrides = None;
243 }
244
245 for profile in available_profiles {
246 let label =
247 format!("{} ({})", profile.name, profile.manufacturer);
248 let value = Some(profile.name.clone());
249 ui.selectable_value(
250 &mut config.device_profile,
251 value,
252 label,
253 );
254 }
255 });
256
257 // Show profile info when one is selected. M-6: surface the
258 // device's supported formats / rates / depths / channels
259 // so the user knows what the lock is hiding, not just
260 // that something is hidden.
261 if let Some(ref name) = config.device_profile {
262 if let Some(profile) =
263 available_profiles.iter().find(|p| &p.name == name)
264 {
265 ui.label(
266 egui::RichText::new(format!("by {}", profile.manufacturer))
267 .small()
268 .color(theme::text_muted()),
269 );
270 if let Some(ref summary) = profile.format_summary {
271 ui.label(
272 egui::RichText::new(summary)
273 .small()
274 .color(theme::text_muted()),
275 );
276 }
277 if let Some(ref category) = profile.category {
278 ui.label(
279 egui::RichText::new(category)
280 .small()
281 .color(theme::text_muted()),
282 );
283 }
284 if let Some(ref notes) = profile.notes {
285 ui.label(
286 egui::RichText::new(notes)
287 .small()
288 .color(theme::text_muted()),
289 );
290 }
291 }
292 }
293 }
294 ui.add_space(theme::space::MD);
295 }
296
297 // --- Format ---
298 let has_profile = matches!(
299 &state.import_mode,
300 ImportMode::ConfigureExport {
301 config: ExportConfig { device_profile: Some(_), .. },
302 ..
303 }
304 );
305
306 if !has_profile {
307 ui.label(egui::RichText::new("Format").strong());
308 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
309 let is_original = config.format == ExportFormat::Original;
310 let is_wav = config.format == ExportFormat::Wav;
311 let is_aiff = config.format == ExportFormat::Aiff;
312
313 if ui.radio(is_original, "Original (copy as-is)").clicked() && !is_original {
314 config.format = ExportFormat::Original;
315 }
316 if ui.radio(is_wav, "WAV (decode and re-encode)").clicked() && !is_wav {
317 config.format = ExportFormat::Wav;
318 }
319 if ui.radio(is_aiff, "AIFF (decode and re-encode)").clicked() && !is_aiff {
320 config.format = ExportFormat::Aiff;
321 }
322 }
323 ui.add_space(theme::space::MD);
324
325 // --- Audio encoding options (WAV/AIFF) ---
326 let needs_encoding_options = matches!(
327 &state.import_mode,
328 ImportMode::ConfigureExport {
329 config: ExportConfig {
330 format: ExportFormat::Wav | ExportFormat::Aiff,
331 ..
332 },
333 ..
334 }
335 );
336
337 if needs_encoding_options {
338 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
339 // Sample rate
340 ui.label(egui::RichText::new("Sample Rate").strong());
341 let rates: [(Option<u32>, &str); 4] = [
342 (None, "Original"),
343 (Some(44100), "44,100 Hz"),
344 (Some(48000), "48,000 Hz"),
345 (Some(96000), "96,000 Hz"),
346 ];
347 for (rate, label) in &rates {
348 if ui.radio(config.sample_rate == *rate, *label).clicked() {
349 config.sample_rate = *rate;
350 }
351 }
352 ui.add_space(theme::space::MD);
353
354 // Bit depth
355 ui.label(egui::RichText::new("Bit Depth").strong());
356 let depths: [(Option<u16>, &str); 3] = [
357 (None, "Original"),
358 (Some(16), "16-bit"),
359 (Some(24), "24-bit"),
360 ];
361 for (depth, label) in &depths {
362 if ui.radio(config.bit_depth == *depth, *label).clicked() {
363 config.bit_depth = *depth;
364 }
365 }
366 ui.add_space(theme::space::MD);
367 }
368 }
369
370 // --- Channels ---
371 ui.label(egui::RichText::new("Channels").strong());
372 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
373 let ch_options: [(ExportChannels, &str); 3] = [
374 (ExportChannels::Original, "Original"),
375 (ExportChannels::Mono, "Mono"),
376 (ExportChannels::Stereo, "Stereo"),
377 ];
378 for (ch, label) in &ch_options {
379 if ui.radio(config.channels == *ch, *label).clicked() {
380 config.channels = ch.clone();
381 }
382 }
383 }
384 ui.add_space(theme::space::MD);
385 }
386
387 // --- Structure ---
388 ui.label(egui::RichText::new("Structure").strong());
389 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
390 if ui
391 .radio(!config.flatten, "Preserve tree")
392 .clicked()
393 && config.flatten
394 {
395 config.flatten = false;
396 }
397 if ui
398 .radio(config.flatten, "Flatten (all files in one folder)")
399 .clicked()
400 && !config.flatten
401 {
402 config.flatten = true;
403 }
404 }
405 ui.add_space(theme::space::MD);
406
407 // --- Metadata sidecar ---
408 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
409 ui.checkbox(
410 &mut config.metadata_sidecar,
411 "Include metadata (.audiofiles.json)",
412 );
413 }
414 ui.add_space(theme::space::MD);
415
416 // --- Naming pattern (when flattened) ---
417 if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_mode {
418 if config.flatten {
419 ui.label(egui::RichText::new("Naming Pattern").strong());
420 let mut pattern = config.naming_pattern.clone().unwrap_or_default();
421
422 // Token chips (M-8): clicking appends the token to the
423 // pattern. egui doesn't surface the cursor position on
424 // TextEdit so append-to-end is the honest affordance.
425 ui.horizontal_wrapped(|ui| {
426 ui.label(
427 egui::RichText::new("Tokens:")
428 .small()
429 .color(theme::text_muted()),
430 );
431 const TOKENS: &[&str] = &[
432 "{name}", "{bpm}", "{key}", "{class}", "{duration}",
433 "{n}", "{nn}", "{nnn}", "{ext}",
434 ];
435 for tok in TOKENS {
436 if ui
437 .small_button(*tok)
438 .on_hover_text("Append this token to the pattern")
439 .clicked()
440 {
441 pattern.push_str(tok);
442 }
443 }
444 });
445
446 let changed = ui.text_edit_singleline(&mut pattern).changed();
447 if changed
448 || config.naming_pattern.as_deref().unwrap_or("") != pattern
449 {
450 config.naming_pattern =
451 if pattern.is_empty() { None } else { Some(pattern.clone()) };
452 }
453
454 // Live preview (M-7): parse + resolve against the first
455 // item's context. Parse errors (unknown token, unclosed
456 // brace) render in yellow so the user catches typos before
457 // committing to a 200-file export.
458 if !pattern.is_empty() {
459 match audiofiles_core::rename::RenamePattern::parse(&pattern) {
460 Ok(parsed) => {
461 if let Some(first) = items.first() {
462 let ctx = audiofiles_core::rename::RenameContext {
463 name: first.name.clone(),
464 extension: first.ext.clone(),
465 bpm: first.bpm,
466 musical_key: first.musical_key.clone(),
467 classification: first.classification.clone(),
468 duration: first.duration,
469 index: 0,
470 };
471 let stem = parsed.resolve(&ctx);
472 let preview = if first.ext.is_empty() {
473 stem
474 } else {
475 format!("{stem}.{}", first.ext)
476 };
477 ui.label(
478 egui::RichText::new(format!("Preview: {preview}"))
479 .small()
480 .color(theme::text_muted()),
481 );
482 }
483 }
484 Err(e) => {
485 ui.label(
486 egui::RichText::new(format!("Pattern: {e}"))
487 .small()
488 .color(theme::accent_yellow()),
489 );
490 }
491 }
492 }
493 ui.add_space(theme::space::MD);
494 }
495 }
496
497 // --- Destination ---
498 ui.label(egui::RichText::new("Destination").strong());
499 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
500 ui.horizontal(|ui| {
501 let dest_display = config.destination.display().to_string();
502 ui.label(&dest_display);
503 if ui.button("Browse...").clicked() {
504 if let Some(path) = rfd::FileDialog::new()
505 .set_title("Export Destination")
506 .set_directory(&config.destination)
507 .pick_folder()
508 {
509 config.destination = path;
510 }
511 }
512 });
513 }
514 });
515 });
516 }
517
518 /// Draw the export progress screen.
519 pub fn draw_export_progress(ctx: &egui::Context, state: &mut BrowserState) {
520 let (completed, total, current_name) = match &state.import_mode {
521 ImportMode::Exporting {
522 completed,
523 total,
524 current_name,
525 } => (*completed, *total, current_name.clone()),
526 _ => return,
527 };
528
529 egui::CentralPanel::default().show(ctx, |ui| {
530 ui.heading("Exporting...");
531 ui.add_space(theme::space::SECTION);
532
533 if total > 0 {
534 let progress = completed as f32 / total as f32;
535 ui.add(egui::ProgressBar::new(progress).show_percentage());
536 ui.add_space(theme::space::MD);
537 ui.label(format!("{completed} / {total}"));
538 } else {
539 // m-4: mirror the spinner pattern from the other progress screens'
540 // pre-first-item moment so the surface reads as busy rather than stuck.
541 ui.horizontal(|ui| {
542 ui.spinner();
543 ui.label("Starting export...");
544 });
545 }
546
547 if !current_name.is_empty() {
548 ui.add_space(theme::space::SM);
549 ui.label(
550 egui::RichText::new(format!("Exporting: {current_name}"))
551 .small()
552 .color(theme::text_muted()),
553 );
554 }
555
556 ui.add_space(theme::space::SECTION);
557 if ui.button("Cancel").clicked() {
558 state.cancel_export();
559 }
560 });
561 }
562
563 /// Draw the export complete screen with summary and error list.
564 pub fn draw_export_complete(ctx: &egui::Context, state: &mut BrowserState) {
565 let (total, error_count) = match &state.import_mode {
566 ImportMode::ExportComplete { total, errors } => (*total, errors.len()),
567 _ => return,
568 };
569
570 egui::CentralPanel::default().show(ctx, |ui| {
571 ui.heading("Export Complete");
572 ui.add_space(theme::space::LG);
573
574 if error_count == 0 {
575 ui.label(format!("Successfully exported {total} files."));
576 } else {
577 ui.label(format!(
578 "Exported {total} files with {error_count} errors."
579 ));
580 ui.add_space(theme::space::MD);
581
582 if let ImportMode::ExportComplete { ref errors, .. } = state.import_mode {
583 egui::ScrollArea::vertical()
584 .max_height(200.0)
585 .show(ui, |ui| {
586 for (name, err) in errors {
587 // m-8: name in accent_red + body in text_secondary
588 // so errors don't blend with hint text. Mirrors the
589 // two-label layout in progress.rs / summary.rs.
590 ui.horizontal(|ui| {
591 ui.label(
592 egui::RichText::new(name)
593 .small()
594 .color(theme::accent_red()),
595 );
596 ui.label(
597 egui::RichText::new(err)
598 .small()
599 .color(theme::text_secondary()),
600 );
601 });
602 }
603 });
604 }
605 }
606
607 ui.add_space(theme::space::SECTION);
608 ui.horizontal(|ui| {
609 // m-9: primary_button for the anchor moment of the export flow.
610 if widgets::primary_button(ui, "Done").clicked() {
611 state.import_mode = ImportMode::None;
612 }
613 // p-1: open the destination folder so users can verify the result
614 // without navigating Finder/Explorer themselves. Suppressed when
615 // the destination wasn't stashed (e.g. export driven from outside
616 // the wizard's run_export path).
617 if let Some(dest) = state.last_export_destination.clone() {
618 if ui.button("Open destination folder").clicked() {
619 #[cfg(target_os = "macos")]
620 let _ = std::process::Command::new("open").arg(&dest).spawn();
621 #[cfg(target_os = "linux")]
622 let _ = std::process::Command::new("xdg-open").arg(&dest).spawn();
623 #[cfg(target_os = "windows")]
624 let _ = std::process::Command::new("cmd")
625 .args(["/c", "start", "", &dest.display().to_string()])
626 .spawn();
627 }
628 }
629 });
630 });
631 }
632