Skip to main content

max / audiofiles

28.2 KB · 641 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(ui: &mut egui::Ui, 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::Panel::bottom("export_footer").show_inside(ui, |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 && let Some(profile) = available_profiles.iter().find(|p| &p.name == profile_name)
120 && 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 // Disk space check (M-3): estimate from actual per-item durations
153 // and the current encoding config rather than a fixed 10 MB/item
154 // heuristic. Only warn when the projection exceeds available space
155 // with a 10% headroom. Yellow because this is an anticipation
156 // warning, not a confirmed failure.
157 if let Some(available) = available_disk_space(&config.destination) {
158 let bps = bytes_per_sec_for_config(config);
159 let estimated_bytes: u64 = items
160 .iter()
161 .filter_map(|i| i.duration)
162 .map(|d| (d.max(0.0) * bps as f64) as u64)
163 .sum();
164 if estimated_bytes > 0 && (estimated_bytes as f64) * 1.1 > available as f64 {
165 ui.label(
166 egui::RichText::new(format!(
167 "Low disk space: {:.1} GB available, ~{:.1} GB needed",
168 available as f64 / 1_073_741_824.0,
169 estimated_bytes as f64 / 1_073_741_824.0,
170 ))
171 .small()
172 .color(theme::accent_yellow()),
173 );
174 }
175 }
176 }
177
178 ui.horizontal(|ui| {
179 if ui.button("Cancel").clicked() {
180 state.import_mode = ImportMode::None;
181 }
182 if ui.button("Export").clicked()
183 && let ImportMode::ConfigureExport { ref items, ref config, .. } =
184 state.import_mode
185 {
186 let items = items.clone();
187 let config = config.clone();
188 state.run_export(items, config);
189 }
190 });
191 ui.add_space(theme::space::XS);
192 });
193
194 egui::CentralPanel::default().show_inside(ui, |ui| {
195 egui::ScrollArea::vertical().show(ui, |ui| {
196 ui.heading("Export Samples");
197 ui.add_space(theme::space::SM);
198 ui.horizontal(|ui| {
199 ui.label(format!("{item_count} samples to export"));
200 if profile_count > 0 {
201 ui.label(
202 egui::RichText::new(format!("\u{00B7} {} device profiles available", profile_count))
203 .small()
204 .color(theme::text_muted()),
205 );
206 }
207 });
208 ui.add_space(theme::space::LG);
209
210 // --- Device Profile ---
211 if profile_count > 0 {
212 ui.label(egui::RichText::new("Device Profile").strong());
213 if let ImportMode::ConfigureExport {
214 ref mut config,
215 ref available_profiles,
216 ..
217 } = state.import_mode
218 {
219 let current_label = config
220 .device_profile
221 .as_deref()
222 .unwrap_or("None (manual)");
223
224 egui::ComboBox::from_id_salt("device_profile_picker")
225 .selected_text(current_label)
226 .width(250.0)
227 .show_ui(ui, |ui| {
228 if ui
229 .selectable_value(
230 &mut config.device_profile,
231 None,
232 "None (manual)",
233 )
234 .clicked()
235 {
236 // Reset profile-derived fields when switching to manual
237 config.naming_rules = None;
238 config.max_file_size_bytes = None;
239 config.name_overrides = None;
240 }
241
242 for profile in available_profiles {
243 let label =
244 format!("{} ({})", profile.name, profile.manufacturer);
245 let value = Some(profile.name.clone());
246 ui.selectable_value(
247 &mut config.device_profile,
248 value,
249 label,
250 );
251 }
252 });
253
254 // Show profile info when one is selected. M-6: surface the
255 // device's supported formats / rates / depths / channels
256 // so the user knows what the lock is hiding, not just
257 // that something is hidden.
258 if let Some(ref name) = config.device_profile
259 && let Some(profile) =
260 available_profiles.iter().find(|p| &p.name == name)
261 {
262 ui.label(
263 egui::RichText::new(format!("by {}", profile.manufacturer))
264 .small()
265 .color(theme::text_muted()),
266 );
267 if let Some(ref summary) = profile.format_summary {
268 ui.label(
269 egui::RichText::new(summary)
270 .small()
271 .color(theme::text_muted()),
272 );
273 }
274 if let Some(ref category) = profile.category {
275 ui.label(
276 egui::RichText::new(category)
277 .small()
278 .color(theme::text_muted()),
279 );
280 }
281 if let Some(ref notes) = profile.notes {
282 ui.label(
283 egui::RichText::new(notes)
284 .small()
285 .color(theme::text_muted()),
286 );
287 }
288 }
289 }
290 ui.add_space(theme::space::MD);
291 }
292
293 // --- Format ---
294 let has_profile = matches!(
295 &state.import_mode,
296 ImportMode::ConfigureExport {
297 config: ExportConfig { device_profile: Some(_), .. },
298 ..
299 }
300 );
301
302 if !has_profile {
303 ui.label(egui::RichText::new("Format").strong());
304 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
305 let is_original = config.format == ExportFormat::Original;
306 let is_wav = config.format == ExportFormat::Wav;
307 let is_aiff = config.format == ExportFormat::Aiff;
308
309 if ui.radio(is_original, "Original (copy as-is)").clicked() && !is_original {
310 config.format = ExportFormat::Original;
311 }
312 if ui.radio(is_wav, "WAV (decode and re-encode)").clicked() && !is_wav {
313 config.format = ExportFormat::Wav;
314 }
315 if ui.radio(is_aiff, "AIFF (decode and re-encode)").clicked() && !is_aiff {
316 config.format = ExportFormat::Aiff;
317 }
318
319 // Heads-up: WAV/AIFF re-encode writes only fmt+data /
320 // COMM+SSND. Embedded BWF (bext), iXML, smpl loop points,
321 // cue markers, and ID3 tags are not preserved. Choose
322 // Original to keep them intact.
323 if config.format != ExportFormat::Original {
324 ui.add_space(theme::space::XS);
325 ui.label(
326 egui::RichText::new(
327 "Re-encoding strips embedded metadata chunks \
328 (BWF, iXML, loop points, cue markers, ID3). \
329 Choose Original to preserve them.",
330 )
331 .small()
332 .color(theme::accent_yellow()),
333 );
334 }
335 }
336 ui.add_space(theme::space::MD);
337
338 // --- Audio encoding options (WAV/AIFF) ---
339 let needs_encoding_options = matches!(
340 &state.import_mode,
341 ImportMode::ConfigureExport {
342 config: ExportConfig {
343 format: ExportFormat::Wav | ExportFormat::Aiff,
344 ..
345 },
346 ..
347 }
348 );
349
350 if needs_encoding_options
351 && let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
352 // Sample rate
353 ui.label(egui::RichText::new("Sample Rate").strong());
354 let rates: [(Option<u32>, &str); 4] = [
355 (None, "Original"),
356 (Some(44100), "44,100 Hz"),
357 (Some(48000), "48,000 Hz"),
358 (Some(96000), "96,000 Hz"),
359 ];
360 for (rate, label) in &rates {
361 if ui.radio(config.sample_rate == *rate, *label).clicked() {
362 config.sample_rate = *rate;
363 }
364 }
365 ui.add_space(theme::space::MD);
366
367 // Bit depth
368 ui.label(egui::RichText::new("Bit Depth").strong());
369 let depths: [(Option<u16>, &str); 3] = [
370 (None, "Original"),
371 (Some(16), "16-bit"),
372 (Some(24), "24-bit"),
373 ];
374 for (depth, label) in &depths {
375 if ui.radio(config.bit_depth == *depth, *label).clicked() {
376 config.bit_depth = *depth;
377 }
378 }
379 ui.add_space(theme::space::MD);
380 }
381
382 // --- Channels ---
383 ui.label(egui::RichText::new("Channels").strong());
384 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
385 let ch_options: [(ExportChannels, &str); 3] = [
386 (ExportChannels::Original, "Original"),
387 (ExportChannels::Mono, "Mono"),
388 (ExportChannels::Stereo, "Stereo"),
389 ];
390 for (ch, label) in &ch_options {
391 if ui.radio(config.channels == *ch, *label).clicked() {
392 config.channels = ch.clone();
393 }
394 }
395 }
396 ui.add_space(theme::space::MD);
397 }
398
399 // --- Structure ---
400 ui.label(egui::RichText::new("Structure").strong());
401 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
402 if ui
403 .radio(!config.flatten, "Preserve tree")
404 .clicked()
405 && config.flatten
406 {
407 config.flatten = false;
408 }
409 if ui
410 .radio(config.flatten, "Flatten (all files in one folder)")
411 .clicked()
412 && !config.flatten
413 {
414 config.flatten = true;
415 }
416 }
417 ui.add_space(theme::space::MD);
418
419 // --- Metadata sidecar ---
420 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
421 ui.checkbox(
422 &mut config.metadata_sidecar,
423 "Include metadata (.audiofiles.json)",
424 );
425 }
426 ui.add_space(theme::space::MD);
427
428 // --- Naming pattern (when flattened) ---
429 if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_mode
430 && config.flatten {
431 ui.label(egui::RichText::new("Naming Pattern").strong());
432 let mut pattern = config.naming_pattern.clone().unwrap_or_default();
433
434 // Token chips (M-8): clicking appends the token to the
435 // pattern. egui doesn't surface the cursor position on
436 // TextEdit so append-to-end is the honest affordance.
437 ui.horizontal_wrapped(|ui| {
438 ui.label(
439 egui::RichText::new("Tokens:")
440 .small()
441 .color(theme::text_muted()),
442 );
443 const TOKENS: &[&str] = &[
444 "{name}", "{bpm}", "{key}", "{class}", "{duration}",
445 "{n}", "{nn}", "{nnn}", "{ext}",
446 ];
447 for tok in TOKENS {
448 if ui
449 .small_button(*tok)
450 .on_hover_text("Append this token to the pattern")
451 .clicked()
452 {
453 pattern.push_str(tok);
454 }
455 }
456 });
457
458 let changed = ui.text_edit_singleline(&mut pattern).changed();
459 if changed
460 || config.naming_pattern.as_deref().unwrap_or("") != pattern
461 {
462 config.naming_pattern =
463 if pattern.is_empty() { None } else { Some(pattern.clone()) };
464 }
465
466 // Live preview (M-7): parse + resolve against the first
467 // item's context. Parse errors (unknown token, unclosed
468 // brace) render in yellow so the user catches typos before
469 // committing to a 200-file export.
470 if !pattern.is_empty() {
471 match audiofiles_core::rename::RenamePattern::parse(&pattern) {
472 Ok(parsed) => {
473 if let Some(first) = items.first() {
474 let ctx = audiofiles_core::rename::RenameContext {
475 name: first.name.clone(),
476 extension: first.ext.clone(),
477 bpm: first.bpm,
478 musical_key: first.musical_key.clone(),
479 classification: first.classification.clone(),
480 duration: first.duration,
481 index: 0,
482 };
483 let stem = parsed.resolve(&ctx);
484 let preview = if first.ext.is_empty() {
485 stem
486 } else {
487 format!("{stem}.{}", first.ext)
488 };
489 ui.label(
490 egui::RichText::new(format!("Preview: {preview}"))
491 .small()
492 .color(theme::text_muted()),
493 );
494 }
495 }
496 Err(e) => {
497 ui.label(
498 egui::RichText::new(format!("Pattern: {e}"))
499 .small()
500 .color(theme::accent_yellow()),
501 );
502 }
503 }
504 }
505 ui.add_space(theme::space::MD);
506 }
507
508 // --- Destination ---
509 ui.label(egui::RichText::new("Destination").strong());
510 if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode {
511 ui.horizontal(|ui| {
512 let dest_display = config.destination.display().to_string();
513 ui.label(&dest_display);
514 if ui.button("Browse...").clicked()
515 && let Some(path) = rfd::FileDialog::new()
516 .set_title("Export Destination")
517 .set_directory(&config.destination)
518 .pick_folder()
519 {
520 config.destination = path;
521 }
522 });
523 }
524 });
525 });
526 }
527
528 /// Draw the export progress screen.
529 pub fn draw_export_progress(ui: &mut egui::Ui, state: &mut BrowserState) {
530 let (completed, total, current_name) = match &state.import_mode {
531 ImportMode::Exporting {
532 completed,
533 total,
534 current_name,
535 } => (*completed, *total, current_name.clone()),
536 _ => return,
537 };
538
539 egui::CentralPanel::default().show_inside(ui, |ui| {
540 ui.heading("Exporting...");
541 ui.add_space(theme::space::SECTION);
542
543 if total > 0 {
544 let progress = completed as f32 / total as f32;
545 ui.add(egui::ProgressBar::new(progress).show_percentage());
546 ui.add_space(theme::space::MD);
547 ui.label(format!("{completed} / {total}"));
548 } else {
549 // m-4: mirror the spinner pattern from the other progress screens'
550 // pre-first-item moment so the surface reads as busy rather than stuck.
551 ui.horizontal(|ui| {
552 ui.spinner();
553 ui.label("Starting export...");
554 });
555 }
556
557 if !current_name.is_empty() {
558 ui.add_space(theme::space::SM);
559 ui.label(
560 egui::RichText::new(format!("Exporting: {current_name}"))
561 .small()
562 .color(theme::text_muted()),
563 );
564 }
565
566 ui.add_space(theme::space::SECTION);
567 if ui.button("Cancel").clicked() {
568 state.cancel_export();
569 }
570 });
571 }
572
573 /// Draw the export complete screen with summary and error list.
574 pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) {
575 let (total, error_count) = match &state.import_mode {
576 ImportMode::ExportComplete { total, errors } => (*total, errors.len()),
577 _ => return,
578 };
579
580 egui::CentralPanel::default().show_inside(ui, |ui| {
581 ui.heading("Export Complete");
582 ui.add_space(theme::space::LG);
583
584 if error_count == 0 {
585 ui.label(format!("Successfully exported {total} files."));
586 } else {
587 ui.label(format!(
588 "Exported {total} files with {error_count} errors."
589 ));
590 ui.add_space(theme::space::MD);
591
592 if let ImportMode::ExportComplete { ref errors, .. } = state.import_mode {
593 egui::ScrollArea::vertical()
594 .max_height(200.0)
595 .show(ui, |ui| {
596 for (name, err) in errors {
597 // m-8: name in accent_red + body in text_secondary
598 // so errors don't blend with hint text. Mirrors the
599 // two-label layout in progress.rs / summary.rs.
600 ui.horizontal(|ui| {
601 ui.label(
602 egui::RichText::new(name)
603 .small()
604 .color(theme::accent_red()),
605 );
606 ui.label(
607 egui::RichText::new(err)
608 .small()
609 .color(theme::text_secondary()),
610 );
611 });
612 }
613 });
614 }
615 }
616
617 ui.add_space(theme::space::SECTION);
618 ui.horizontal(|ui| {
619 // m-9: primary_button for the anchor moment of the export flow.
620 if widgets::primary_button(ui, "Done").clicked() {
621 state.import_mode = ImportMode::None;
622 }
623 // p-1: open the destination folder so users can verify the result
624 // without navigating Finder/Explorer themselves. Suppressed when
625 // the destination wasn't stashed (e.g. export driven from outside
626 // the wizard's run_export path).
627 if let Some(dest) = state.last_export_destination.clone()
628 && ui.button("Open destination folder").clicked() {
629 #[cfg(target_os = "macos")]
630 let _ = std::process::Command::new("open").arg(&dest).spawn();
631 #[cfg(target_os = "linux")]
632 let _ = std::process::Command::new("xdg-open").arg(&dest).spawn();
633 #[cfg(target_os = "windows")]
634 let _ = std::process::Command::new("cmd")
635 .args(["/c", "start", "", &dest.display().to_string()])
636 .spawn();
637 }
638 });
639 });
640 }
641