| 69 |
69 |
|
/// Waveform display with playback cursor and click-to-seek.
|
| 70 |
70 |
|
fn draw_waveform_section(ui: &mut egui::Ui, state: &mut BrowserState, hash: &str) {
|
| 71 |
71 |
|
if let Some(ref waveform_data) = state.selected_waveform {
|
|
72 |
+ |
// Show the playhead whenever this sample is the active preview and a
|
|
73 |
+ |
// buffer is loaded — including while paused, so the user can see where
|
|
74 |
+ |
// playback sits before auditioning a trim.
|
| 72 |
75 |
|
let playback_pos = if state.previewing_hash.as_deref() == Some(hash) {
|
| 73 |
76 |
|
let playback = state.shared.preview.lock();
|
| 74 |
|
- |
if playback.playing {
|
| 75 |
|
- |
if let Some(ref buf) = playback.buffer {
|
| 76 |
|
- |
let total_frames = if playback.streaming {
|
| 77 |
|
- |
playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
|
| 78 |
|
- |
} else {
|
| 79 |
|
- |
buf.data.len() / 2
|
| 80 |
|
- |
};
|
| 81 |
|
- |
if total_frames > 0 {
|
| 82 |
|
- |
Some((playback.position_frac / total_frames as f64) as f32)
|
| 83 |
|
- |
} else {
|
| 84 |
|
- |
None
|
| 85 |
|
- |
}
|
|
77 |
+ |
playback.buffer.as_ref().and_then(|buf| {
|
|
78 |
+ |
let total_frames = if playback.streaming {
|
|
79 |
+ |
playback.total_frames_estimate.unwrap_or(playback.decoded_frames)
|
| 86 |
80 |
|
} else {
|
| 87 |
|
- |
None
|
| 88 |
|
- |
}
|
| 89 |
|
- |
} else {
|
| 90 |
|
- |
None
|
| 91 |
|
- |
}
|
|
81 |
+ |
buf.data.len() / 2
|
|
82 |
+ |
};
|
|
83 |
+ |
(total_frames > 0)
|
|
84 |
+ |
.then(|| (playback.position_frac / total_frames as f64) as f32)
|
|
85 |
+ |
})
|
| 92 |
86 |
|
} else {
|
| 93 |
87 |
|
None
|
| 94 |
88 |
|
};
|
| 123 |
117 |
|
overlay,
|
| 124 |
118 |
|
);
|
| 125 |
119 |
|
}
|
| 126 |
|
- |
// Boundary markers reinforce the cut points when the overlay is
|
| 127 |
|
- |
// ambiguous (e.g. nearly-zero trim where the dimmed strip is thin).
|
| 128 |
|
- |
let stroke = egui::Stroke::new(1.0, theme::accent_yellow());
|
| 129 |
|
- |
if trim_start > 0.0 {
|
| 130 |
|
- |
let x = rect.left() + rect.width() * trim_start;
|
| 131 |
|
- |
painter.line_segment(
|
| 132 |
|
- |
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
|
| 133 |
|
- |
stroke,
|
| 134 |
|
- |
);
|
|
120 |
+ |
}
|
|
121 |
+ |
|
|
122 |
+ |
// Draggable trim handles drawn over the waveform. These are the primary
|
|
123 |
+ |
// way to set the cut region; the Start/End sliders below are the numeric
|
|
124 |
+ |
// path. Handles are always present (even at the 0.0/1.0 default) so the
|
|
125 |
+ |
// user can grab either edge directly.
|
|
126 |
+ |
{
|
|
127 |
+ |
let rect = resp.rect;
|
|
128 |
+ |
let (new_start, start_dragged) =
|
|
129 |
+ |
trim_handle(ui, rect, state.edit.trim_start, "edit_trim_handle_start");
|
|
130 |
+ |
let (new_end, end_dragged) =
|
|
131 |
+ |
trim_handle(ui, rect, state.edit.trim_end, "edit_trim_handle_end");
|
|
132 |
+ |
// Only the handle the user is actually dragging moves; it clamps
|
|
133 |
+ |
// against the other so the untouched handle never jumps (and a
|
|
134 |
+ |
// minimum region is preserved).
|
|
135 |
+ |
const MIN_GAP: f32 = 0.001;
|
|
136 |
+ |
if start_dragged {
|
|
137 |
+ |
state.edit.trim_start = new_start.clamp(0.0, state.edit.trim_end - MIN_GAP);
|
| 135 |
138 |
|
}
|
| 136 |
|
- |
if trim_end < 1.0 {
|
| 137 |
|
- |
let x = rect.left() + rect.width() * trim_end;
|
| 138 |
|
- |
painter.line_segment(
|
| 139 |
|
- |
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
|
| 140 |
|
- |
stroke,
|
| 141 |
|
- |
);
|
|
139 |
+ |
if end_dragged {
|
|
140 |
+ |
state.edit.trim_end = new_end.clamp(state.edit.trim_start + MIN_GAP, 1.0);
|
| 142 |
141 |
|
}
|
| 143 |
142 |
|
}
|
| 144 |
143 |
|
|
| 164 |
163 |
|
}
|
| 165 |
164 |
|
}
|
| 166 |
165 |
|
|
|
166 |
+ |
/// Draw a draggable trim-boundary handle over the waveform at `frac` (0..1).
|
|
167 |
+ |
/// Returns `(new_frac, dragged)`. The hit area is wider than the painted line
|
|
168 |
+ |
/// (Fitts) so it is easy to grab, the cursor switches to a horizontal resize on
|
|
169 |
+ |
/// hover, and the line thickens while hovered or dragged for tactile feedback.
|
|
170 |
+ |
fn trim_handle(ui: &mut egui::Ui, rect: egui::Rect, frac: f32, id_salt: &str) -> (f32, bool) {
|
|
171 |
+ |
let frac = frac.clamp(0.0, 1.0);
|
|
172 |
+ |
let x = rect.left() + rect.width() * frac;
|
|
173 |
+ |
let hit = egui::Rect::from_min_max(
|
|
174 |
+ |
egui::pos2(x - 4.0, rect.top()),
|
|
175 |
+ |
egui::pos2(x + 4.0, rect.bottom()),
|
|
176 |
+ |
);
|
|
177 |
+ |
let resp = ui.interact(hit, ui.id().with(id_salt), egui::Sense::drag());
|
|
178 |
+ |
let active = resp.hovered() || resp.dragged();
|
|
179 |
+ |
if active {
|
|
180 |
+ |
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
|
181 |
+ |
}
|
|
182 |
+ |
let mut new_frac = frac;
|
|
183 |
+ |
if resp.dragged() {
|
|
184 |
+ |
if let Some(p) = resp.interact_pointer_pos() {
|
|
185 |
+ |
new_frac = ((p.x - rect.left()) / rect.width()).clamp(0.0, 1.0);
|
|
186 |
+ |
}
|
|
187 |
+ |
}
|
|
188 |
+ |
let width = if active { 2.5 } else { 1.0 };
|
|
189 |
+ |
ui.painter_at(rect).line_segment(
|
|
190 |
+ |
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
|
|
191 |
+ |
egui::Stroke::new(width, theme::accent_yellow()),
|
|
192 |
+ |
);
|
|
193 |
+ |
(new_frac, resp.dragged())
|
|
194 |
+ |
}
|
|
195 |
+ |
|
| 167 |
196 |
|
/// Info line: name, sample rate, duration, peak dB.
|
| 168 |
197 |
|
fn draw_info_line(ui: &mut egui::Ui, state: &BrowserState) {
|
| 169 |
198 |
|
if let Some(ref analysis) = state.selected_analysis {
|
| 172 |
201 |
|
.unwrap_or_default();
|
| 173 |
202 |
|
let duration = analysis.duration;
|
| 174 |
203 |
|
let sr = analysis.sample_rate;
|
| 175 |
|
- |
let peak_str = analysis.peak_db
|
| 176 |
|
- |
.map(|p| format!("{:.1} dBFS", p))
|
|
204 |
+ |
// Only append the peak segment (and its separator) when a value exists,
|
|
205 |
+ |
// so a missing peak_db doesn't leave a dangling " \u{00B7} ".
|
|
206 |
+ |
let peak_suffix = analysis.peak_db
|
|
207 |
+ |
.map(|p| format!(" \u{00B7} {:.1} dBFS", p))
|
| 177 |
208 |
|
.unwrap_or_default();
|
| 178 |
209 |
|
|
| 179 |
210 |
|
ui.horizontal_wrapped(|ui| {
|
| 180 |
211 |
|
ui.label(egui::RichText::new(&name).strong().size(12.0));
|
| 181 |
212 |
|
ui.label(
|
| 182 |
|
- |
egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s \u{00B7} {}", sr, duration, peak_str))
|
|
213 |
+ |
egui::RichText::new(format!("{} Hz \u{00B7} {:.3}s{}", sr, duration, peak_suffix))
|
| 183 |
214 |
|
.color(theme::text_muted())
|
| 184 |
215 |
|
.size(11.0),
|
| 185 |
216 |
|
);
|