Skip to main content

max / audiofiles

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