Skip to main content

max / audiofiles

13.1 KB · 342 lines History Blame Raw
1 use egui;
2 use super::super::{theme, widgets};
3
4 use crate::import::ImportStrategy;
5 use crate::state::{BrowserState, ImportMode};
6
7
8 /// Draw the import configuration screen with strategy radio buttons.
9 pub fn draw_configure_import(ui: &mut egui::Ui, state: &mut BrowserState) {
10 let (source_display, file_count) = match &state.import_mode {
11 ImportMode::ConfigureImport { source, audio_file_count, .. } => {
12 (source.display().to_string(), *audio_file_count)
13 }
14 _ => return,
15 };
16
17 egui::CentralPanel::default().show_inside(ui, |ui| {
18 // Scroll the body so the Cancel/Import row stays reachable on a short
19 // window or when the merge combo + vault field + warnings all expand (P5).
20 egui::ScrollArea::vertical().show(ui, |ui| {
21 widgets::wizard_steps(ui, super::WIZARD_STEPS, 0);
22 ui.heading("Import Folder");
23 ui.add_space(theme::space::MD);
24 // Source: path + Change button (M-5). Picking the wrong folder no
25 // longer requires Cancel-and-restart from the toolbar.
26 ui.horizontal(|ui| {
27 ui.label(format!("Source: {source_display}"));
28 if ui
29 .small_button("Change...")
30 .on_hover_text("Pick a different source folder")
31 .clicked()
32 && let Some(new_source) = rfd::FileDialog::new()
33 .set_title("Choose source folder")
34 .pick_folder()
35 {
36 state.change_import_source(new_source);
37 }
38 });
39 ui.add_space(theme::space::SM);
40
41 // Dry-run preview
42 ui.label(
43 egui::RichText::new(format!(
44 "{file_count} audio file{} found",
45 if file_count == 1 { "" } else { "s" },
46 ))
47 .strong(),
48 );
49 ui.label(
50 egui::RichText::new(format!(
51 "Supported: {}. Duplicates will be skipped automatically.",
52 audiofiles_core::util::AUDIO_EXTENSIONS.join(", "),
53 ))
54 .small()
55 .weak(),
56 );
57 ui.add_space(theme::space::LG);
58
59 ui.label("Import strategy:");
60 ui.add_space(theme::space::SM);
61
62 let is_flat = matches!(
63 &state.import_mode,
64 ImportMode::ConfigureImport { strategy: ImportStrategy::Flat { .. }, .. }
65 );
66 let is_new_vfs = matches!(
67 &state.import_mode,
68 ImportMode::ConfigureImport { strategy: ImportStrategy::NewVfs { .. }, .. }
69 );
70 let is_merge = matches!(
71 &state.import_mode,
72 ImportMode::ConfigureImport { strategy: ImportStrategy::MergeIntoVfs { .. }, .. }
73 );
74
75 let current_vfs_id = state.current_vfs_id();
76 let current_dir = state.current_dir;
77 if ui.radio(is_flat, "Flat (all files in current directory)").clicked() && !is_flat
78 && let (ImportMode::ConfigureImport { strategy, .. }, Some(vfs_id)) =
79 (&mut state.import_mode, current_vfs_id)
80 {
81 *strategy = ImportStrategy::Flat {
82 vfs_id,
83 parent_id: current_dir,
84 };
85 }
86
87 if ui.radio(is_new_vfs, "New vault (preserve directory structure)").clicked() && !is_new_vfs
88 && let ImportMode::ConfigureImport {
89 ref mut strategy,
90 ref new_vfs_name,
91 ..
92 } = state.import_mode
93 {
94 *strategy = ImportStrategy::NewVfs {
95 vfs_name: new_vfs_name.clone(),
96 };
97 }
98
99 if is_new_vfs {
100 ui.indent("new_vfs_indent", |ui| {
101 ui.horizontal(|ui| {
102 ui.label("Vault name:");
103 if let ImportMode::ConfigureImport {
104 ref mut new_vfs_name,
105 ref mut strategy,
106 ..
107 } = state.import_mode
108 && ui.text_edit_singleline(new_vfs_name).changed() {
109 *strategy = ImportStrategy::NewVfs {
110 vfs_name: new_vfs_name.clone(),
111 };
112 }
113 });
114 });
115 }
116
117 if ui.radio(is_merge, "Merge into existing vault").clicked() && !is_merge
118 && let ImportMode::ConfigureImport {
119 ref mut strategy,
120 ref available_vfs,
121 selected_merge_vfs_idx,
122 ..
123 } = state.import_mode
124 {
125 let vfs = &available_vfs[selected_merge_vfs_idx];
126 *strategy = ImportStrategy::MergeIntoVfs {
127 vfs_id: vfs.id,
128 parent_id: None,
129 };
130 }
131
132 if is_merge {
133 ui.indent("merge_vfs_indent", |ui| {
134 if let ImportMode::ConfigureImport {
135 ref mut strategy,
136 ref available_vfs,
137 ref mut selected_merge_vfs_idx,
138 ..
139 } = state.import_mode
140 {
141 let current_name = available_vfs
142 .get(*selected_merge_vfs_idx)
143 .map(|v| v.name.as_str())
144 .unwrap_or("(none)");
145
146 egui::ComboBox::from_id_salt("merge_vfs_select")
147 .selected_text(current_name)
148 .show_ui(ui, |ui| {
149 for (i, vfs) in available_vfs.iter().enumerate() {
150 if ui
151 .selectable_label(i == *selected_merge_vfs_idx, &vfs.name)
152 .clicked()
153 {
154 *selected_merge_vfs_idx = i;
155 *strategy = ImportStrategy::MergeIntoVfs {
156 vfs_id: vfs.id,
157 parent_id: None,
158 };
159 }
160 }
161 });
162 }
163 });
164 }
165
166 ui.add_space(theme::space::SECTION);
167
168 // One-way edge warning (C-1). Configure → Importing is the only
169 // non-recoverable transition in the wizard: once files start landing
170 // in the content store, cancelling preserves the partial work rather
171 // than rolling it back. Make that explicit so the user reads "Import"
172 // as a commit, not a preview.
173 ui.label(
174 egui::RichText::new(
175 "Once started, you can cancel mid-import but copies already made will stay in the library."
176 )
177 .small()
178 .color(theme::text_muted()),
179 );
180 ui.add_space(theme::space::SM);
181
182 // m-11: gate Import on required fields per strategy variant. NewVfs
183 // needs a non-empty vault name; MergeIntoVfs needs at least one vault
184 // available (the indexed access on available_vfs would otherwise panic
185 // if the user reached this screen with no vaults).
186 let (can_import, disabled_reason) = match &state.import_mode {
187 ImportMode::ConfigureImport { strategy, new_vfs_name, available_vfs, .. } => {
188 match strategy {
189 ImportStrategy::Flat { .. } => (true, ""),
190 ImportStrategy::NewVfs { .. } => {
191 if new_vfs_name.trim().is_empty() {
192 (false, "Enter a name for the new vault.")
193 } else {
194 (true, "")
195 }
196 }
197 ImportStrategy::MergeIntoVfs { .. } => {
198 if available_vfs.is_empty() {
199 (false, "No existing vaults to merge into.")
200 } else {
201 (true, "")
202 }
203 }
204 }
205 }
206 _ => (false, ""),
207 };
208
209 ui.horizontal(|ui| {
210 if ui.button("Cancel").clicked() {
211 state.import_mode = ImportMode::None;
212 }
213 let import_btn = ui.add_enabled(can_import, egui::Button::new("Import"));
214 let import_btn = if !can_import && !disabled_reason.is_empty() {
215 import_btn.on_disabled_hover_text(disabled_reason)
216 } else {
217 import_btn
218 };
219 if import_btn.clicked()
220 && let ImportMode::ConfigureImport {
221 ref source,
222 strategy: ref strat,
223 ref new_vfs_name,
224 ref available_vfs,
225 selected_merge_vfs_idx,
226 ..
227 } = state.import_mode
228 {
229 let source = source.clone();
230 let strategy = match strat {
231 ImportStrategy::Flat { vfs_id, parent_id } => ImportStrategy::Flat {
232 vfs_id: *vfs_id,
233 parent_id: *parent_id,
234 },
235 ImportStrategy::NewVfs { .. } => ImportStrategy::NewVfs {
236 vfs_name: new_vfs_name.clone(),
237 },
238 ImportStrategy::MergeIntoVfs { .. } => {
239 let vfs = &available_vfs[selected_merge_vfs_idx];
240 ImportStrategy::MergeIntoVfs {
241 vfs_id: vfs.id,
242 parent_id: None,
243 }
244 }
245 };
246 state.start_folder_import(source, strategy);
247 }
248 });
249 });
250 });
251 }
252
253 /// Draw the analysis configuration screen.
254 pub fn draw_configure_analysis(ui: &mut egui::Ui, state: &mut BrowserState) {
255 let (sample_count, mut config) = match &state.import_mode {
256 ImportMode::ConfigureAnalysis {
257 sample_hashes,
258 config,
259 } => (sample_hashes.len(), config.clone()),
260 _ => return,
261 };
262
263 egui::CentralPanel::default().show_inside(ui, |ui| {
264 widgets::wizard_steps(ui, super::WIZARD_STEPS, 2);
265 ui.heading("Configure Analysis");
266 ui.add_space(theme::space::MD);
267 ui.label(format!("{sample_count} samples to analyze"));
268 ui.add_space(theme::space::LG);
269
270 ui.checkbox(&mut config.loudness, "Loudness (Peak, RMS, LUFS)");
271 ui.checkbox(&mut config.bpm, "BPM Detection");
272 ui.checkbox(&mut config.key, "Key Detection");
273 ui.checkbox(&mut config.spectral, "Spectral Features");
274 ui.checkbox(&mut config.classify, "Auto-classify (requires Spectral)");
275 ui.checkbox(&mut config.loop_detect, "Loop Detection");
276 ui.checkbox(&mut config.auto_suggest_tags, "Auto-suggest Tags");
277 ui.checkbox(&mut config.fingerprint, "Fingerprint (duplicate detection)");
278
279 if config.classify {
280 ui.indent("smart_skip_indent", |ui| {
281 ui.checkbox(
282 &mut config.smart_skip,
283 "Smart skip (skip BPM/key for drums, noise, etc.)",
284 );
285 });
286 }
287
288 // Classification depends on spectral features (centroid, flatness, ZCR, etc.),
289 // so force spectral on when classify is checked.
290 if config.classify && !config.spectral {
291 config.spectral = true;
292 }
293
294 // Smart skip requires classification
295 if !config.classify {
296 config.smart_skip = false;
297 }
298
299 ui.add_space(theme::space::SECTION);
300
301 ui.horizontal(|ui| {
302 // Back (C-1): return to the TagFolders screen with previously
303 // entered tag inputs restored. add_tag is INSERT OR IGNORE, so any
304 // tags applied on the first pass don't double up if the user
305 // commits again. Disabled when nothing's stashed (e.g. reached
306 // here outside the folder-import flow).
307 let can_back = state.last_folder_tags.is_some();
308 if ui
309 .add_enabled(can_back, egui::Button::new("Back"))
310 .on_hover_text("Return to the folder tagging step")
311 .clicked()
312 {
313 state.back_to_tag_folders();
314 return;
315 }
316 if ui.button("Run Analysis").clicked() {
317 let hashes = match &state.import_mode {
318 ImportMode::ConfigureAnalysis { sample_hashes, .. } => {
319 sample_hashes.clone()
320 }
321 _ => Vec::new(),
322 };
323 state.run_analysis(hashes, config.clone());
324 return;
325 }
326
327 if ui.button("Skip analysis").clicked() {
328 state.import_mode = ImportMode::None;
329 state.status = "Imported. Run analysis from the sidebar when ready.".to_string();
330 }
331 });
332 });
333
334 if let ImportMode::ConfigureAnalysis {
335 config: ref mut cfg,
336 ..
337 } = state.import_mode
338 {
339 *cfg = config;
340 }
341 }
342