Skip to main content

max / audiofiles

Editor: transport row, click-to-seek audition, in-progress bar Editor UX from the ux-audit pass (toward the beta gate): - Add a Play/Pause/Stop transport row that auditions the sample being edited, independent of the main file-list selection — so the user can hear a sample before committing a destructive edit. - Click-to-seek now starts preview if the sample isn't already the active preview (was a silent no-op), so clicking the waveform auditions from the clicked point. - In-progress state renders as a top bar with spinner + Cancel while the (self-disabling) body stays visible, instead of collapsing the whole panel to a lone spinner. - Disambiguate the three identical "Apply" buttons: "Apply gain", "Normalize", "Apply fade". - Promote the Replace-mode advisory from a small yellow footnote to a framed warning_banner. 806 tests green, 0 warnings. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-06 01:15 UTC
Commit: e587cb4828eb4422b15079b6e408043b9b0bc242
Parent: 59017a6
1 file changed, +55 insertions, -26 deletions
@@ -13,21 +13,23 @@ use super::widgets;
13 13 pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
14 14 let mut open = state.edit.show_window;
15 15 widgets::tool_window(ctx, "Sample Editor", &mut open, 400.0, 320.0, |ui| {
16 - // In-progress overlay
16 + // In-progress bar. Drawn at the top while an edit applies; the body
17 + // below stays rendered (every section greys itself out via its own
18 + // `in_progress` disabled flag) so the user keeps the waveform and
19 + // controls for spatial reference instead of the panel collapsing to a
20 + // lone spinner.
17 21 if state.edit.in_progress {
18 - ui.vertical_centered(|ui| {
19 - ui.add_space(theme::space::MD);
22 + ui.horizontal(|ui| {
20 23 ui.spinner();
21 24 ui.label("Applying edit...");
22 25 // M-11: best-effort cancel. Signals the worker and clears
23 26 // in_progress so the UI is interactive even if the worker
24 27 // is mid-write — the cancel is advisory, not synchronous.
25 - ui.add_space(theme::space::SM);
26 28 if ui.button("Cancel").clicked() {
27 29 state.cancel_edit_operation();
28 30 }
29 31 });
30 - return;
32 + ui.separator();
31 33 }
32 34
33 35 // Result prompt overlay
@@ -43,6 +45,7 @@ pub fn draw_edit_window(ctx: &egui::Context, state: &mut BrowserState) {
43 45
44 46 draw_waveform_section(ui, state, &hash);
45 47 draw_info_line(ui, state);
48 + draw_transport_section(ui, state, &hash);
46 49
47 50 ui.separator();
48 51 draw_trim_section(ui, state);
@@ -141,28 +144,54 @@ fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str
141 144 }
142 145 }
143 146
144 - // Click-to-seek
147 + // Click-to-seek. If this sample isn't the active preview yet, start it
148 + // first so a click in the editor auditions from the clicked point —
149 + // previously the click was a silent no-op until preview was started
150 + // elsewhere.
145 151 if resp.clicked() {
146 152 if let Some(pos) = resp.interact_pointer_pos() {
147 153 let rect = resp.rect;
148 154 let normalized = ((pos.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
149 - if state.previewing_hash.as_deref() == Some(hash) {
150 - let mut playback = state.shared.preview.lock();
151 - if let Some(ref buf) = playback.buffer {
152 - let total_frames = if playback.streaming {
153 - playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
154 - } else {
155 - buf.data.len() / 2
156 - };
157 - playback.position_frac = (normalized as f64 * total_frames as f64)
158 - .min((playback.decoded_frames.max(1) - 1) as f64);
159 - }
155 + if state.previewing_hash.as_deref() != Some(hash) {
156 + state.trigger_preview(hash);
157 + }
158 + let mut playback = state.shared.preview.lock();
159 + if let Some(ref buf) = playback.buffer {
160 + let total_frames = if playback.streaming {
161 + playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
162 + } else {
163 + buf.data.len() / 2
164 + };
165 + playback.position_frac = (normalized as f64 * total_frames as f64)
166 + .min((playback.decoded_frames.max(1) - 1) as f64);
160 167 }
161 168 }
162 169 }
163 170 }
164 171 }
165 172
173 + /// Transport row: Play/Pause/Stop for the sample being edited, independent of
174 + /// the main file-list selection so the user can audition before committing a
175 + /// destructive edit.
176 + fn draw_transport_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
177 + let is_current = state.previewing_hash.as_deref() == Some(hash);
178 + let playing = is_current && state.shared.preview.lock().playing;
179 + ui.horizontal(|ui| {
180 + if widgets::secondary_button(ui, if playing { "Pause" } else { "Play" }).clicked() {
181 + if is_current {
182 + // Toggle play/pause on the already-loaded buffer.
183 + let mut pb = state.shared.preview.lock();
184 + pb.playing = !pb.playing;
185 + } else {
186 + state.trigger_preview(hash);
187 + }
188 + }
189 + if widgets::secondary_button(ui, "Stop").clicked() {
190 + state.stop_preview();
191 + }
192 + });
193 + }
194 +
166 195 /// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1).
167 196 /// Returns `(new_frac, dragged)`. The hit area is wider than the painted line
168 197 /// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on
@@ -278,7 +307,7 @@ fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) {
278 307 ui.horizontal(|ui| {
279 308 ui.label("Gain:");
280 309 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.gain_db, -24.0..=24.0).suffix(" dB"));
281 - if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
310 + if ui.add_enabled(!disabled, egui::Button::new("Apply gain")).clicked() {
282 311 state.apply_edit_gain();
283 312 }
284 313 });
@@ -308,7 +337,7 @@ fn draw_levels_section(ui: &mut egui::Ui, state: &mut BrowserState) {
308 337
309 338 ui.horizontal(|ui| {
310 339 ui.add_enabled(!disabled, egui::Slider::new(&mut state.edit.norm_target, norm_range).suffix(norm_suffix));
311 - if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
340 + if ui.add_enabled(!disabled, egui::Button::new("Normalize")).clicked() {
312 341 state.apply_edit_normalize();
313 342 }
314 343 });
@@ -356,7 +385,7 @@ fn draw_transform_section(ui: &mut egui::Ui, state: &mut BrowserState) {
356 385 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::Logarithmic, "Log");
357 386 ui.selectable_value(&mut state.edit.fade_curve, FadeCurve::SCurve, "S-Curve");
358 387 });
359 - if ui.add_enabled(!disabled, egui::Button::new("Apply")).clicked() {
388 + if ui.add_enabled(!disabled, egui::Button::new("Apply fade")).clicked() {
360 389 state.apply_edit_fade();
361 390 }
362 391 });
@@ -521,12 +550,12 @@ fn draw_result_section(ui: &mut egui::Ui, state: &mut BrowserState) {
521 550 // points the user at Create sibling as the non-destructive default for
522 551 // workflows that want to keep the original in the VFS.
523 552 if matches!(state.edit.result_mode, Some(EditResultMode::Replace)) {
524 - ui.label(
525 - egui::RichText::new(
526 - "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.",
527 - )
528 - .small()
529 - .color(theme::accent_yellow()),
553 + // Promoted from small yellow footnote to a framed warning banner — the
554 + // consequence (original removed from the vault) is important enough that
555 + // the footnote style under-sold it.
556 + widgets::warning_banner(
557 + ui,
558 + "Replace mode: the original is removed from this vault. Use Create sibling to keep both, or click Undo on the next line within 10s to revert.",
530 559 );
531 560 }
532 561