//! Export workflow screens: configure export settings, progress bar, and completion summary. use std::path::Path; use egui; use crate::state::{BrowserState, ImportMode}; use audiofiles_core::export::{ExportChannels, ExportConfig, ExportFormat}; use super::{theme, widgets}; /// Query available disk space on the filesystem containing the given path. #[cfg(unix)] fn available_disk_space(path: &Path) -> Option { use std::ffi::CString; use std::os::unix::ffi::OsStrExt; let c_path = CString::new(path.as_os_str().as_bytes()).ok()?; // SAFETY: `statvfs` is a POSIX FFI call. `c_path` is a valid NUL-terminated // C string (from CString::new). `stat` is zero-initialized, which is a valid // representation for libc::statvfs. The pointer to `stat` is valid for the // duration of the call. unsafe { let mut stat: libc::statvfs = std::mem::zeroed(); if libc::statvfs(c_path.as_ptr(), &mut stat) == 0 { Some(stat.f_bavail as u64 * stat.f_frsize as u64) } else { None } } } #[cfg(windows)] fn available_disk_space(path: &Path) -> Option { use std::os::windows::ffi::OsStrExt; let wide: Vec = path.as_os_str().encode_wide().chain(std::iter::once(0)).collect(); let mut free_bytes: u64 = 0; // SAFETY: `GetDiskFreeSpaceExW` is a Win32 FFI call. `wide` is a valid // NUL-terminated UTF-16 string (from encode_wide + chain(once(0))). // `free_bytes` is a valid aligned u64 for the out-parameter. The pointer // to `wide` is valid for the duration of the call. unsafe { if windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW( windows::core::PCWSTR(wide.as_ptr()), Some(&mut free_bytes), None, None, ).is_ok() { Some(free_bytes) } else { None } } } #[cfg(not(any(unix, windows)))] fn available_disk_space(_path: &Path) -> Option { None } /// Effective bytes-per-second of audio under the current export config. /// Used by the disk-space and AIFF-size pre-flight warnings (M-3 / M-4) so /// the magnitude warnings reflect the user's actual selection rather than a /// worst-case heuristic. Defaults (`None` config values) bias high so we err /// on the side of warning when the user picks "Original". fn bytes_per_sec_for_config(config: &ExportConfig) -> u64 { let rate = config.sample_rate.unwrap_or(48_000) as u64; let depth_bytes = (config.bit_depth.unwrap_or(24) as u64).div_ceil(8); let channels = match config.channels { ExportChannels::Mono => 1u64, ExportChannels::Stereo => 2u64, ExportChannels::Original => 2u64, }; rate.saturating_mul(depth_bytes).saturating_mul(channels) } /// Draw the export configuration screen. pub fn draw_configure_export(ui: &mut egui::Ui, state: &mut BrowserState) { let (item_count, profile_count) = match &state.import_mode { ImportMode::ConfigureExport { items, available_profiles, .. } => (items.len(), available_profiles.len()), _ => return, }; egui::Panel::bottom("export_footer").show_inside(ui, |ui| { ui.add_space(theme::space::SM); // Warnings if let ImportMode::ConfigureExport { ref items, ref config, ref available_profiles, .. } = state.import_mode { // AIFF size limit warning (M-4): the 4 GB chunk limit translates // to ~124 minutes at the worst-case config (stereo 24-bit 96kHz) // and considerably more at smaller depths/rates. Compute the // actual safe duration from the current config rather than warning // at a fixed 20-minute threshold. Yellow because this is // anticipation, not error. if config.format == ExportFormat::Aiff { let max_duration = items.iter().filter_map(|i| i.duration).fold(0.0f64, f64::max); let bps = bytes_per_sec_for_config(config) as f64; // 90% of u32::MAX gives headroom for chunk headers + rounding. let safe_secs = (u32::MAX as f64 * 0.9) / bps.max(1.0); if max_duration > safe_secs { ui.label( egui::RichText::new(format!( "Warning: AIFF chunks cap at 4 GB. At the current rate/depth/channels, \ samples longer than ~{:.0} min may fail to export.", safe_secs / 60.0, )) .small() .color(theme::accent_yellow()), ); } } // Device file size limit warning if let Some(ref profile_name) = config.device_profile && let Some(profile) = available_profiles.iter().find(|p| &p.name == profile_name) && let Some(max_bytes) = profile.max_file_size_bytes { // Estimate: duration * sample_rate * channels * bytes_per_sample // Use worst case: stereo 24-bit at 48kHz = 288000 bytes/sec let bytes_per_sec: f64 = 288_000.0; let over_limit: Vec<&str> = items.iter() .filter(|item| { item.duration .map(|d| (d * bytes_per_sec) as u64 > max_bytes) .unwrap_or(false) }) .map(|item| item.name.as_str()) .collect(); if !over_limit.is_empty() { let msg = if over_limit.len() == 1 { format!( "\"{}\" may exceed device file size limit ({:.0} MB)", over_limit[0], max_bytes as f64 / 1_048_576.0, ) } else { format!( "{} samples may exceed device file size limit ({:.0} MB)", over_limit.len(), max_bytes as f64 / 1_048_576.0, ) }; ui.label( egui::RichText::new(msg).small().color(theme::accent_red()), ); } } // Disk space check (M-3): estimate from actual per-item durations // and the current encoding config rather than a fixed 10 MB/item // heuristic. Only warn when the projection exceeds available space // with a 10% headroom. Yellow because this is an anticipation // warning, not a confirmed failure. if let Some(available) = available_disk_space(&config.destination) { let bps = bytes_per_sec_for_config(config); let estimated_bytes: u64 = items .iter() .filter_map(|i| i.duration) .map(|d| (d.max(0.0) * bps as f64) as u64) .sum(); if estimated_bytes > 0 && (estimated_bytes as f64) * 1.1 > available as f64 { ui.label( egui::RichText::new(format!( "Low disk space: {:.1} GB available, ~{:.1} GB needed", available as f64 / 1_073_741_824.0, estimated_bytes as f64 / 1_073_741_824.0, )) .small() .color(theme::accent_yellow()), ); } } } ui.horizontal(|ui| { if ui.button("Cancel").clicked() { state.import_mode = ImportMode::None; } if ui.button("Export").clicked() && let ImportMode::ConfigureExport { ref items, ref config, .. } = state.import_mode { let items = items.clone(); let config = config.clone(); state.run_export(items, config); } }); ui.add_space(theme::space::XS); }); egui::CentralPanel::default().show_inside(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| { ui.heading("Export Samples"); ui.add_space(theme::space::SM); ui.horizontal(|ui| { ui.label(format!("{item_count} samples to export")); if profile_count > 0 { ui.label( egui::RichText::new(format!("\u{00B7} {} device profiles available", profile_count)) .small() .color(theme::text_muted()), ); } }); ui.add_space(theme::space::LG); // --- Device Profile --- if profile_count > 0 { ui.label(egui::RichText::new("Device Profile").strong()); if let ImportMode::ConfigureExport { ref mut config, ref available_profiles, .. } = state.import_mode { let current_label = config .device_profile .as_deref() .unwrap_or("None (manual)"); egui::ComboBox::from_id_salt("device_profile_picker") .selected_text(current_label) .width(250.0) .show_ui(ui, |ui| { if ui .selectable_value( &mut config.device_profile, None, "None (manual)", ) .clicked() { // Reset profile-derived fields when switching to manual config.naming_rules = None; config.max_file_size_bytes = None; config.name_overrides = None; } for profile in available_profiles { let label = format!("{} ({})", profile.name, profile.manufacturer); let value = Some(profile.name.clone()); ui.selectable_value( &mut config.device_profile, value, label, ); } }); // Show profile info when one is selected. M-6: surface the // device's supported formats / rates / depths / channels // so the user knows what the lock is hiding, not just // that something is hidden. if let Some(ref name) = config.device_profile && let Some(profile) = available_profiles.iter().find(|p| &p.name == name) { ui.label( egui::RichText::new(format!("by {}", profile.manufacturer)) .small() .color(theme::text_muted()), ); if let Some(ref summary) = profile.format_summary { ui.label( egui::RichText::new(summary) .small() .color(theme::text_muted()), ); } if let Some(ref category) = profile.category { ui.label( egui::RichText::new(category) .small() .color(theme::text_muted()), ); } if let Some(ref notes) = profile.notes { ui.label( egui::RichText::new(notes) .small() .color(theme::text_muted()), ); } } } ui.add_space(theme::space::MD); } // --- Format --- let has_profile = matches!( &state.import_mode, ImportMode::ConfigureExport { config: ExportConfig { device_profile: Some(_), .. }, .. } ); if !has_profile { ui.label(egui::RichText::new("Format").strong()); if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { let is_original = config.format == ExportFormat::Original; let is_wav = config.format == ExportFormat::Wav; let is_aiff = config.format == ExportFormat::Aiff; if ui.radio(is_original, "Original (copy as-is)").clicked() && !is_original { config.format = ExportFormat::Original; } if ui.radio(is_wav, "WAV (decode and re-encode)").clicked() && !is_wav { config.format = ExportFormat::Wav; } if ui.radio(is_aiff, "AIFF (decode and re-encode)").clicked() && !is_aiff { config.format = ExportFormat::Aiff; } // Heads-up: WAV/AIFF re-encode writes only fmt+data / // COMM+SSND. Embedded BWF (bext), iXML, smpl loop points, // cue markers, and ID3 tags are not preserved. Choose // Original to keep them intact. if config.format != ExportFormat::Original { ui.add_space(theme::space::XS); ui.label( egui::RichText::new( "Re-encoding strips embedded metadata chunks \ (BWF, iXML, loop points, cue markers, ID3). \ Choose Original to preserve them.", ) .small() .color(theme::accent_yellow()), ); } } ui.add_space(theme::space::MD); // --- Audio encoding options (WAV/AIFF) --- let needs_encoding_options = matches!( &state.import_mode, ImportMode::ConfigureExport { config: ExportConfig { format: ExportFormat::Wav | ExportFormat::Aiff, .. }, .. } ); if needs_encoding_options && let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { // Sample rate ui.label(egui::RichText::new("Sample Rate").strong()); let rates: [(Option, &str); 4] = [ (None, "Original"), (Some(44100), "44,100 Hz"), (Some(48000), "48,000 Hz"), (Some(96000), "96,000 Hz"), ]; for (rate, label) in &rates { if ui.radio(config.sample_rate == *rate, *label).clicked() { config.sample_rate = *rate; } } ui.add_space(theme::space::MD); // Bit depth ui.label(egui::RichText::new("Bit Depth").strong()); let depths: [(Option, &str); 3] = [ (None, "Original"), (Some(16), "16-bit"), (Some(24), "24-bit"), ]; for (depth, label) in &depths { if ui.radio(config.bit_depth == *depth, *label).clicked() { config.bit_depth = *depth; } } ui.add_space(theme::space::MD); } // --- Channels --- ui.label(egui::RichText::new("Channels").strong()); if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { let ch_options: [(ExportChannels, &str); 3] = [ (ExportChannels::Original, "Original"), (ExportChannels::Mono, "Mono"), (ExportChannels::Stereo, "Stereo"), ]; for (ch, label) in &ch_options { if ui.radio(config.channels == *ch, *label).clicked() { config.channels = ch.clone(); } } } ui.add_space(theme::space::MD); } // --- Structure --- ui.label(egui::RichText::new("Structure").strong()); if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { if ui .radio(!config.flatten, "Preserve tree") .clicked() && config.flatten { config.flatten = false; } if ui .radio(config.flatten, "Flatten (all files in one folder)") .clicked() && !config.flatten { config.flatten = true; } } ui.add_space(theme::space::MD); // --- Metadata sidecar --- if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { ui.checkbox( &mut config.metadata_sidecar, "Include metadata (.audiofiles.json)", ); } ui.add_space(theme::space::MD); // --- Naming pattern (when flattened) --- if let ImportMode::ConfigureExport { ref mut config, ref items, .. } = state.import_mode && config.flatten { ui.label(egui::RichText::new("Naming Pattern").strong()); let mut pattern = config.naming_pattern.clone().unwrap_or_default(); // Token chips (M-8): clicking appends the token to the // pattern. egui doesn't surface the cursor position on // TextEdit so append-to-end is the honest affordance. ui.horizontal_wrapped(|ui| { ui.label( egui::RichText::new("Tokens:") .small() .color(theme::text_muted()), ); const TOKENS: &[&str] = &[ "{name}", "{bpm}", "{key}", "{class}", "{duration}", "{n}", "{nn}", "{nnn}", "{ext}", ]; for tok in TOKENS { if ui .small_button(*tok) .on_hover_text("Append this token to the pattern") .clicked() { pattern.push_str(tok); } } }); let changed = ui.text_edit_singleline(&mut pattern).changed(); if changed || config.naming_pattern.as_deref().unwrap_or("") != pattern { config.naming_pattern = if pattern.is_empty() { None } else { Some(pattern.clone()) }; } // Live preview (M-7): parse + resolve against the first // item's context. Parse errors (unknown token, unclosed // brace) render in yellow so the user catches typos before // committing to a 200-file export. if !pattern.is_empty() { match audiofiles_core::rename::RenamePattern::parse(&pattern) { Ok(parsed) => { if let Some(first) = items.first() { let ctx = audiofiles_core::rename::RenameContext { name: first.name.clone(), extension: first.ext.clone(), bpm: first.bpm, musical_key: first.musical_key.clone(), classification: first.classification.clone(), duration: first.duration, index: 0, }; let stem = parsed.resolve(&ctx); let preview = if first.ext.is_empty() { stem } else { format!("{stem}.{}", first.ext) }; ui.label( egui::RichText::new(format!("Preview: {preview}")) .small() .color(theme::text_muted()), ); } } Err(e) => { ui.label( egui::RichText::new(format!("Pattern: {e}")) .small() .color(theme::accent_yellow()), ); } } } ui.add_space(theme::space::MD); } // --- Destination --- ui.label(egui::RichText::new("Destination").strong()); if let ImportMode::ConfigureExport { ref mut config, .. } = state.import_mode { ui.horizontal(|ui| { let dest_display = config.destination.display().to_string(); ui.label(&dest_display); if ui.button("Browse...").clicked() && let Some(path) = rfd::FileDialog::new() .set_title("Export Destination") .set_directory(&config.destination) .pick_folder() { config.destination = path; } }); } }); }); } /// Draw the export progress screen. pub fn draw_export_progress(ui: &mut egui::Ui, state: &mut BrowserState) { let (completed, total, current_name) = match &state.import_mode { ImportMode::Exporting { completed, total, current_name, } => (*completed, *total, current_name.clone()), _ => return, }; egui::CentralPanel::default().show_inside(ui, |ui| { ui.heading("Exporting..."); ui.add_space(theme::space::SECTION); if total > 0 { let progress = completed as f32 / total as f32; ui.add(egui::ProgressBar::new(progress).show_percentage()); ui.add_space(theme::space::MD); ui.label(format!("{completed} / {total}")); } else { // m-4: mirror the spinner pattern from the other progress screens' // pre-first-item moment so the surface reads as busy rather than stuck. ui.horizontal(|ui| { ui.spinner(); ui.label("Starting export..."); }); } if !current_name.is_empty() { ui.add_space(theme::space::SM); ui.label( egui::RichText::new(format!("Exporting: {current_name}")) .small() .color(theme::text_muted()), ); } ui.add_space(theme::space::SECTION); if ui.button("Cancel").clicked() { state.cancel_export(); } }); } /// Draw the export complete screen with summary and error list. pub fn draw_export_complete(ui: &mut egui::Ui, state: &mut BrowserState) { let (total, error_count) = match &state.import_mode { ImportMode::ExportComplete { total, errors } => (*total, errors.len()), _ => return, }; egui::CentralPanel::default().show_inside(ui, |ui| { ui.heading("Export Complete"); ui.add_space(theme::space::LG); if error_count == 0 { ui.label(format!("Successfully exported {total} files.")); } else { ui.label(format!( "Exported {total} files with {error_count} errors." )); ui.add_space(theme::space::MD); if let ImportMode::ExportComplete { ref errors, .. } = state.import_mode { egui::ScrollArea::vertical() .max_height(200.0) .show(ui, |ui| { for (name, err) in errors { // m-8: name in accent_red + body in text_secondary // so errors don't blend with hint text. Mirrors the // two-label layout in progress.rs / summary.rs. ui.horizontal(|ui| { ui.label( egui::RichText::new(name) .small() .color(theme::accent_red()), ); ui.label( egui::RichText::new(err) .small() .color(theme::text_secondary()), ); }); } }); } } ui.add_space(theme::space::SECTION); ui.horizontal(|ui| { // m-9: primary_button for the anchor moment of the export flow. if widgets::primary_button(ui, "Done").clicked() { state.import_mode = ImportMode::None; } // p-1: open the destination folder so users can verify the result // without navigating Finder/Explorer themselves. Suppressed when // the destination wasn't stashed (e.g. export driven from outside // the wizard's run_export path). if let Some(dest) = state.last_export_destination.clone() && ui.button("Open destination folder").clicked() { #[cfg(target_os = "macos")] let _ = std::process::Command::new("open").arg(&dest).spawn(); #[cfg(target_os = "linux")] let _ = std::process::Command::new("xdg-open").arg(&dest).spawn(); #[cfg(target_os = "windows")] let _ = std::process::Command::new("cmd") .args(["/c", "start", "", &dest.display().to_string()]) .spawn(); } }); }); }